Compare commits

..

233 Commits

Author SHA1 Message Date
jekkos
5450404cb2 fix: cast string returns to int in MY_Migration (#4560)
basename() returns string and database column values are strings,
but get_latest_migration() and get_current_version() declare int
return types. PHP 8.0+ enforces strict return types and no longer
silently coerces strings to int, causing a TypeError on fresh
installs.

Fixes #4559

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-22 16:07:21 +02:00
objecttothis
b7384296c1 Bugfix: Sale search in register not handling trailing space properly (#4557)
* Fix is_valid_receipt method bug

Strings submitted with a trailing space and no number caused an unhandled exception because Sale::exists() expects an int but a string was passed to it.

- Add guards
- Minor PSR refactor

Signed-off-by: objec <objecttothis@gmail.com>

* Address review comments

Signed-off-by: objec <objecttothis@gmail.com>

---------

Signed-off-by: objec <objecttothis@gmail.com>
2026-05-22 01:43:24 +04:00
objecttothis
b0dddc22a3 Bugfixes to get Migration working on MySQL and MariaDB (#4551)
* Bugfixes to get Migration working on MySQL

Signed-off-by: objec <objecttothis@gmail.com>

* MariaDB compatibility fixes

- Drop foreign key constraints before making charset changes
- Fix dropAllForeignKeyConstraints helper function.
- Added `IF EXISTS` to DROP statements
- Do not try to readd FK constraints for tables which were dropped.
- MariaDB 11.8.x changes the default implicit collation to uca1400 which breaks the IndiaGST migration, et. al. Explicitly declare utf8_general_ci in affected migrations.

Signed-off-by: objec <objecttothis@gmail.com>

* Fix changes which break MySQL migrations

- MySQL does not support IF EXISTS in foreign key constraints. Since the PHP is now handling dropping those constraints, these lines are redundant. Remove them.

Signed-off-by: objec <objecttothis@gmail.com>

* Resolve code review recommendations

- Add try/catch around DB connect statement
- Heed result of execute_script function and throw an exception on failure.

Signed-off-by: objec <objecttothis@gmail.com>

* Refactor out duplicate code

Signed-off-by: objec <objecttothis@gmail.com>

* Initialize array variable causing potential issues

Signed-off-by: objec <objecttothis@gmail.com>

---------

Signed-off-by: objec <objecttothis@gmail.com>
2026-05-19 16:02:05 +04:00
jekkos
8d6b166673 feat: Add deployment workflow with approval gates (#4522)
* feat: Add deployment workflows with approval gates

Add GitHub Actions workflows for controlled deployments:

deploy.yml - Manual Deploy:
- Triggered via Actions UI (workflow_dispatch)
- Select environment (production/staging)
- Select Docker image tag
- Reusable via workflow_call for other workflows
- Creates GitHub deployment records with status tracking
- Sends Docker Hub compatible webhook payload
- Environment input validation for workflow_call

deploy-pr.yml - PR Deploy:
- Auto-triggers when PR is approved (same-repo only)
- Deploys to staging environment
- Image tag format: pr-{number}-{short-sha}
- Posts deployment status as PR comment
- Fork PR protection: only runs for same-repo PRs

Security:
- jq-based JSON payload construction (prevents script injection)
- HMAC-SHA256 signature verification for webhook
- Untrusted inputs via env: blocks (not inline interpolation)
- Environment validation before deployment
- Fork detection guard for PR deployments

Fixes CodeRabbit review comments:
- Invalid jq string filter syntax (missing quotes)
- Unvalidated environment input in workflow_call
- Fork PR deployments blocked by pull_request_review restrictions

* refactor: Limit deployment to staging only

- Remove environment input choice (was production/staging)
- Hardcode environment to 'staging' throughout
- Simplify workflow - no environment validation needed
- Update concurrency group to deploy-staging

* refactor: Extract deployment logic to reusable deploy-core.yml

Restructure workflows to eliminate code duplication:

deploy-core.yml (new):
- Reusable workflow with all deployment logic
- Creates GitHub deployment record
- Sends webhook payload to external service
- Handles status updates
- Accepts image_tag, sha, description, pr_number inputs
- Outputs deployment_id and status

deploy.yml (simplified):
- Manual trigger only
- Calls deploy-core with user-provided image_tag
- 18 lines (was 175)

deploy-pr.yml (simplified):
- PR approval trigger with fork guard
- Prepare job: checkout, generate PR image tag
- Deploy job: calls deploy-core
- Comment job: post status to PR
- 70 lines (was 204)

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-18 21:48:02 +02:00
jekkos
093ec7fb13 fix: validate attributeId > 0 in saveAttributeLink() (#4508)
- Add early validation to reject attributeId <= 0
- Ensure consistent handling of invalid attribute_id in INSERT/UPDATE paths
- Prevent foreign key constraint violations from invalid attribute references

Fixes #4460

Co-authored-by: Ollama <ollama@steganos.dev>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-05-18 14:13:20 +02:00
jekkos
9c89a2e2cb fix: Capture CSV import failures in save_tax_data and save_inventory_quantities (#4507)
* fix: capture CSV import failures in save_tax_data and save_inventory_quantities

- Change save_tax_data() return type from void to bool
- Change save_inventory_quantities() return type from void to bool
- Accumulate failure status with &= operator in save_inventory_quantities
- Update postImportCsvFile() to capture return values and set isFailedRow
- Properly propagate failures to failCodes array

Fixes #4475

* fix: Change isset to !empty for items_taxes_data check

- isset was always true since array was initialized
- Use !empty to properly check if there are tax items to save

Address CodeRabbit review feedback

* fix: Capture inventory insert result in save_inventory_quantities

- Combine inventory insert result with success tracking
- Use &= operator to accumulate failures from both operations
- Ensure failures from inventory inserts are propagated

Address CodeRabbit review feedback

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-17 22:23:43 +02:00
jekkos
2f51c4ef52 fix(security): SQL injection and path traversal vulnerabilities (#4539)
Security fixes for two vulnerabilities:

1. SQL Injection in Summary Sales Taxes Report (GHSA-5j9m-2f98-cjqw)
   - Fixed unsanitized user input concatenation in getData() method
   - Applied proper escaping using $this->db->escape() for start_date/end_date
   - Consistent with existing _where() method implementation

2. Path Traversal in Receipt Template (GHSA-h6wm-fhw2-m3q3)
   - Added ALLOWED_RECEIPT_TEMPLATES whitelist constant
   - Added isValidReceiptTemplate() validation method
   - Validate receipt_template before saving in Config controller
   - Validate receipt_template before rendering in receipt view
   - Default to 'receipt_default' for invalid values
   - Consistent with invoice_type fix pattern (commit 31d25e06d)

Affected files:
- app/Models/Reports/Summary_sales_taxes.php
- app/Libraries/Sale_lib.php
- app/Controllers/Config.php
- app/Views/sales/receipt.php

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-15 23:10:04 +02:00
jekkos
def0c27a0e fix(security): Path traversal vulnerability in getPicThumb (#4545)
Security impact:
- Authenticated attackers could read arbitrary files on the server
- Path traversal via unsanitized pic_filename parameter
- Could read .env, config files, encryption keys

Fix:
- Apply basename() to strip directory components
- Validate file extension to allowlist image types only
- Add explicit error response for invalid file types

CVE: Pending
Affected: <= 3.4.2
Reported by: Kamran Saifullah (VulDB)

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-15 22:04:29 +02:00
BhojKamal
90c981b6b7 feat: Bank transfer and wallet payment option added #4540 (#4547)
---------

Co-authored-by: Lotussoft Youngtech <lotussoftyoungtech@gmail.com>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-05-15 20:50:34 +02:00
jekkos
6ff28d8a4d docs: Update SECURITY.md with disclosure process (#4549)
* docs: Update SECURITY.md with disclosure process and advisory template

- Update published advisories table with CVE-2026-41306 and CVE-2026-41307
- Add disclosure process timeline
- Add vulnerability template for researchers
- Explain GitHub advisory creation workflow
- Document security best practices for researchers

This streamlines the vulnerability reporting process by allowing
researchers to create draft advisories directly on GitHub, reducing
triage overhead.

* docs: Update SECURITY.md with CVE process and reporter acknowledgments

- Add CVE request procedure through GitHub
- Document that existing CVEs should be shared in reports
- Clarify no bug bounty program (voluntary triage)
- Add security best practices for researchers
- Thank security researchers for contributions
- Explain vulnerability template format

* docs: Simplify SECURITY.md - remove CVE table, link to GitHub advisories

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-15 12:24:39 +02:00
jekkos
70fb347fc4 fix(docker): correct permissions and fix migration barcode_type error (#4546)
* fix(ci): include hidden files in Docker build context

actions/upload-artifact@v4 excludes hidden files (dotfiles) by default,
causing .htaccess files to be missing from the Docker image. Add
include-hidden-files: true to preserve .htaccess in the build artifact.

* fix(docker): correct permissions and add barcode_type default

- Set proper permissions (750) for writable/logs, writable/uploads,
  writable/cache, public/uploads, and public/uploads/item_pics
- Set permissions (640) for writable/uploads/importCustomers.csv
- Add barcode_type default value to prevent 'unknown key' error
  during initial migration when database is not yet initialized

---------

Co-authored-by: Ollama <ollama@steganos.dev>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-05-13 20:55:59 +02:00
jekkos
2f5c0130f4 feat: add ALLOWED_HOSTNAMES environment variable support for Docker/Compose (#4544)
Allow configuring allowed hostnames via ALLOWED_HOSTNAMES environment
variable as an alternative to app.allowedHostnames in .env file. This
is more convenient for Docker/Compose deployments where environment
variables are set directly in compose files.

The ALLOWED_HOSTNAMES variable takes precedence over app.allowedHostnames
if both are set, allowing deployment-specific overrides.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Ollama <ollama@steganos.dev>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-13 09:03:32 +02:00
jekkos
fdd6a408ec fix(ci): include hidden files in Docker build context (#4543)
actions/upload-artifact@v4 excludes hidden files (dotfiles) by default,
causing .htaccess files to be missing from the Docker image. Add
include-hidden-files: true to preserve .htaccess in the build artifact.

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-13 07:06:23 +02:00
BudsieBuds
ef91e6a9df chore: sync project files to match upstream templates (#4537)
- updated some files to match the official CodeIgniter 4 skeleton.
- rebuilt package.json from a clean init and modernized metadata and formatting
- rebuilt composer.json with modernized metadata and formatting
- replaced code of conduct text with markdown
- updated Dockerfile to replace deprecated instruction
2026-05-12 15:55:36 +02:00
dependabot[bot]
144e73eba6 chore(deps): bump minimatch from 3.1.2 to 3.1.5 (#4536)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 16:49:39 +04:00
BudsieBuds
42ba39d290 chore: miscellaneous updates and improvements (#4530)
- reinstated 'update-licenses' task in gulp (accidentally removed in 3e844f2f89)
- updated bootstrap, bootswatch, and various dev dependencies
- refinded text across UI
- applied consistency fixes
- added 'number' and 'tel' input types to relevant settings
- improved system info layout (still room for improvement, but better)
- updated and fixed changelog
2026-05-08 09:07:52 +02:00
WShells
81213f0434 Assignable Keyboard Shortcuts Updates (#4532)
* Add configurable sales shortcuts

* Fix sales shortcut payment flow

* Resolve shortcut keys review comment

* Sanitize shortcut config notifications

* Clarify keyboard shortcut configuration labels

---------

Co-authored-by: WShells <26513147+WShells@users.noreply.github.com>
2026-05-07 22:53:25 +04:00
khao_lek
7edefe8ee1 Translated using Weblate (Thai)
Currently translated at 100.0% (15 of 15 strings)

Translation: opensourcepos/login
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/login/th/
2026-04-28 10:06:38 +02:00
khao_lek
68e14191f9 Translated using Weblate (Thai)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/th/
2026-04-28 10:05:06 +02:00
khao_lek
a381c3ca54 Translated using Weblate (Thai)
Currently translated at 99.5% (227 of 228 strings)

Translation: opensourcepos/sales
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/sales/th/
2026-04-28 10:05:06 +02:00
enricodelarosa
058e12244e fix(home): improve internal data type handling for user identification in auth process 2026-04-28 09:56:56 +02:00
jekkos
f1c6fe2981 fix: Catch mysqli_sql_exception in DB fallback handlers for fresh Docker installs (#4525)
* fix: Catch mysqli_sql_exception in DB fallback handlers for fresh Docker installs

On a fresh Docker install with an empty database, the ospos_sessions
table doesn't exist yet. The CSRF filter triggers session initialization
before the login/migration page can be reached.

The existing code in Session.php, OSPOS.php, and MY_Migration.php
catches DatabaseException, but the MySQLi driver throws
mysqli_sql_exception (which extends RuntimeException, not
DatabaseException) when the table doesn't exist. This causes an
unhandled exception resulting in HTTP 500.

Fix: Change all three catch blocks from  to
 so that mysqli_sql_exception and any other unexpected
database errors are caught, allowing the app to fall back gracefully:

- Session.php: Falls back to FileHandler so sessions work without DB
- OSPOS.php: Falls back to empty settings so config loads work
- MY_Migration.php: Falls back to version 0 / false so the migration
  check passes gracefully

This allows the login page with migration UI to be served on first
access, so the initial schema migration can run.

Fixes #4524
---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-22 21:13:52 +02:00
jekkos
ff7a8d2e88 fix: Update calendar translations (#4498)
- Fix typo 'mayl' to 'may' in Calendar.php for lo, ka, ml, nb locales
- Improve Spanish translation in Items.php for csv_import_invalid_location
- Add trailing newlines to Calendar.php files (ka, ml, nb, lo) per PSR-12

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-20 06:48:57 +00:00
jekkos
e602eddb47 fix: Scope orWhere clauses in Item::exists() and Item::get_item_id() (#4520)
In PR #4250 (commit 29c3c55), orWhere was added to match items by
either item_id or item_number, but the OR condition was not wrapped
in groupStart()/groupEnd(). This causes:

1. Wrong SQL semantics: generates
   WHERE item_id = ? OR item_number = ? AND deleted = 0
   instead of
   WHERE (item_id = ? OR item_number = ?) AND deleted = 0
   Due to AND binding tighter than OR, the deleted filter only applies
   to the item_number branch, allowing deleted items to match via item_id.

2. Performance: the unscoped OR causes MySQL to bypass the item_id
   primary key index and fall back to full table scans when item_number
   is a string column compared against a numeric parameter.

Both exists() and get_item_id() are fixed by wrapping the OR
conditions in groupStart()/groupEnd() for proper parenthesization.

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-20 06:22:42 +00:00
jekkos
0a313aa09d fix: Language dropdown not displaying saved language correctly (#4518)
Root cause: In commit 7f9321eca, the refactoring incorrectly used object
notation ($config->language_code) on an array instead of array notation
($config['language_code']).

The settings property in OSPOS config is an array, so:
- $config->language_code returns null (object access on array)
- $config['language_code'] returns the actual value

This caused both functions to always fall back to defaults, making the
language dropdown show incorrect values.

Fix: Change both functions to use array notation:
- Line 25: $config['language_code'] (returns saved language code)
- Line 46: $config['language'] (returns saved language name)

Also fixed the wrong DEFAULT_LANGUAGE_CODE fallback on line 46 - should be
DEFAULT_LANGUAGE since current_language() returns a name not a code.

Fixes #4517

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-19 22:06:11 +02:00
jekkos
12e3c7e31f fix: Add missing $img_tag variable in Sales::getSendPdf() (#4515)
* fix: Add missing $img_tag variable in Sales::getSendPdf()

The receipt_email.php view expects $img_tag but getSendPdf() wasn't passing it.
This caused 'Undefined variable $img_tag' error when sending receipt emails.

Closes #4514

* refactor: Extract img_tag building into helper method

Refactored duplicate img_tag building code into _build_img_tag helper method.
Both getSendPdf and getSendReceipt now use this shared method.

* refactor: Move logo-related methods to Email_lib

Moved buildLogoImgTag and getLogoMimeType methods to Email_lib library
where they logically belong alongside email-related functionality.

This removes duplicate code and centralizes email-related helpers.
Sales controller now uses email_lib->buildLogoImgTag() and
email_lib->getLogoMimeType() instead of private methods.

* fix: Address CodeRabbit review comments

- buildLogoImgTag now uses getLogoMimeType for actual MIME type instead of hardcoding image/png
- getLogoMimeType returns empty string instead of false for consistency
- Consolidated logo path/exists check logic between both methods

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-17 21:02:45 +00:00
jekkos
de62e9f3bd Fix CRC currency reverting to EUR/LAK in locale config (#4511)
Root cause: In postCheckNumberLocale(), when number_locale differed from
save_number_locale (which happens during form typing/validation), the code
ignored user-provided currency values and always used locale defaults.

For example:
- User sets currency_code to "CRC" (Costa Rica Colon)
- checkNumberLocale is called with save_number_locale from hidden field
- If locale values don't match, original code overwrites with locale defaults
- This caused CRC to revert to the default currency for that locale (EUR, LAK, etc.)

Fix: Always respect user-provided currency_symbol and currency_code values
when they are non-empty, regardless of whether locale changed or not.

Fixes #4494

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-17 17:53:46 +00:00
jekkos
97ca738b2d fix: Escape dynamic output and fix CSS property in barcode_sheet.php (#4501)
- Add esc() for dynamic output in HTML attributes and URLs
- Cast numeric values to int for CSS properties
- Fix invalid 'borderspacing' CSS property to 'border-spacing'
- Add quotes around class attribute

Closes #4487

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-16 19:37:06 +00:00
jekkos
c714dd6f68 fix: propagate attribute definition failures in postSaveGeneral() (#4509)
- Wrap attribute definition and appconfig save in single transaction
- Capture return values from saveDefinition() and deleteDefinition()
- Only call batch_save() if attribute operation succeeds
- Combine success status with transStatus() for atomic result
- Prevents category_dropdown config persistence when attribute fails

Fixes #4461

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-16 19:14:50 +00:00
dependabot[bot]
b6f28da058 Bump dompurify from 3.3.2 to 3.4.0 (#4512)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.2 to 3.4.0.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.2...3.4.0)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.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>
2026-04-16 14:14:29 +04:00
objecttothis
165c3351eb Encourage users to star the project
Added a request to star the project for support.
2026-04-15 16:25:06 +04:00
Ollama
905b58ca6e [Fix]: Add missing return statements to Sales Controller functions
- Fix postComplete(): Add return keyword for error redirect paths
  (lines 799, 843, 871) when duplicate invoice/work_order/quote numbers
- Fix postChangeItemNumber(): Add return statement returning JSON response
- Fix postChangeItemName(): Add return statement returning JSON response
- Fix postChangeItemDescription(): Add return statement returning JSON response

All 4 functions declared return types but were missing return statements,
causing potential runtime errors in certain code paths.

Resolves #4492
2026-04-15 06:49:12 +00:00
dependabot[bot]
609b206375 Bump lodash from 4.17.23 to 4.18.1 (#4462)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

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-04-14 01:21:43 +04:00
objecttothis
6fec2464f8 Update to CodeIgniter 4.7.2 (#4485)
- Merge Config and Core File Changes 4.6.3 > 4.6.4
- Merge Config and Core File Changes 4.6.4 > 4.7.0
- Added app\Config\WorkerMode.php
- Merge Config and Core File Changes Not previously merged
- Added app\Config\Hostnames.php
- Corrected incorrect CSS property used in invoice.php view.
- Corrected unknown CSS properties used in register.php view.
- Used shorthand CSS in debug.css
- Corrected indentation in barcode_sheet.php view.
- Corrected indentation in footer.php view.
- Corrected indentation in invoice_email.php view.
- Replaced obsolete attributes with CSS style attributes in barcode_sheet.php
- Replaced obsolete attribute in error_exception.php
- Replaced obsolete attribute in invoice_email.php
- Replaced obsolete attribute in quote_email.php
- Replaced obsolete attributes in work_order_email.php
- Fixed indentation in system_info.php
- Replaced <strong> tag outside <p> tags, which isn't allowed, with style attributes.
- Simplified js return logic and indentation fixes in tax_categories.php
- Simplified js return logic in tax_codes.php
- Simplified js return logic in tax_jurisdictions.php
- Removed unnecessary labels in manage views.
- Rewrite JavaScript function and PHP to be more readable in bar.php, hbar.php, line.php and pie.php
- Added type declarations, return types and an import to app\Config\Services
- Updated Attribute.php parameter type
- Updated Receiving_lib.php parameter type
- Updated Receivings.php parameter types and updated PHPdocs
- Updated tabular_helper.php parameter types and updated PHPdocs
- Added type declarations and corrected PHPdocs in url_helper.php
- Added return types to functions
- Revert $objectSrc value in ContentSecurityPolicy.php
- Correct return type in Customer->get_stats()
- Correct return type in Item->get_info_by_id_or_number()
- Correct misspelling in border-spacing
- Added missing css style semicolons
- Resolve operator precedence ambiguity.
- Resolve column mismatch.
- Added missing escaping in view.
- Updated requirement for PHP 8.2
- Resolve unresolved conflicts
- Added PHP 8.2 requirement to the README.md
- Fixed bugs in display of UI
- Fixed duplicated `>` in app\Views\Expenses\manage.php
- Removed excess whitespace at the end of some lines in table_filter_persistence.php
- Added missing `>` in app\Views\Expenses\manage.php
- Corrected grammar in PHPdoc in table_filter_persistence.php
- Remove bug causing `\` to be injected into the new giftcard value
- Fix bug causing DROPDOWN Attribute Values to not save correctly
- Added check for null in $normalizedItemId

- Removing < PHP 8.2 from linting and tests
- Update Linter to not include PHP 8.2 and 8.1
- Remove PHP 8.1 unit test cycle.
- Update Bug Report Template
- Update Composer files for CodeIgniter 4.7.2
- Updated INSTALL.md to reflect changes.

---------

Signed-off-by: objec <objecttothis@gmail.com>
2026-04-14 01:05:10 +04:00
jekkos
332d8c8c69 fix: change docker image tag to master 2026-04-10 23:58:38 +02:00
objecttothis
577cf55b6a [Feature]: Case-sensitive attribute updates and CSV Import attribute deletion capability (#4384)
PSR and Readability Changes
- Removed unused import
- Corrected PHPdoc to include the correct return type
- Refactored out a function to get attribute data from the row in a CSV item import.
- refactored snake_case variables and function names to camelCase
- Refactored the naming of saveAttributeData() to better reflect the functions purpose.
- Improved PHPdocs
- Remove whitespace
- Remove unneeded comment
- Refactored abbreviated variable name for clarity
- Removed $csvHeaders as it is unused
- Corrected spacing and curly brace location
- Refactored Stock Locations validation inside general validation

Bugfixes
- Fixed bug causing attribute_id and item_id to not be properly assigned when empty() returns true.
- Fixed bug causing CSV Item import to not update barcode when changed in the import file.
- Fixed saveAttributeValue() logic causing attribute_value to be updated to a value that already exists for a different attribute_id
- Fixed bug preventing Category as dropdown functionality from working
- Fixed bug preventing barcodes from updating. in Item CSV Imports
- Corrected bug in stock_location->save_value()
- Corrected incorrect helper file references.
- Removed duplicate call to save attribute link
- Rollback transaction on failure before returning false
- Rollback transaction and return 0 on failure to save attribute link.
- Account for '0' being an acceptable TEXT or DECIMAL attributeValue.
- Corrected Business logic
- Resolved incorrect array key
- Account for 0 in column values
- Correct check empty attribute check
- Previously 0 would have been skipped even though that's a valid value for an attribute.
- Removed unused foreach loop index variables
- Corrected CodeIgniter Framework version to specific version

UnitTest Seeder and tests
- Created a seeder to automatically prepare the test database.
- Modified the Unit Test setup to properly seed the test database.
- Wrote a unit test to test deleting an attribute from an item through the CSV.
- Corrected errors in unit tests preventing them from passing. save_value() returns a bool, not the itemId
- Fix Unit Tests that were failing
- Corrected the logic in itemUpdate test
- Replaced precision test with one reflecting testing of actual value.
- This test does not test cash rounding rules. That should go into a different test.
- Correct expected value in test.
- Update app/Database/Seeds/TestDatabaseBootstrapSeeder.php
- Added check to testImportDeleteAttributeFromExistingItem
- Correct mocking of dropdowns
- Remove code depending on removed database.sql
- Removed FQN in seeder() call
- Added checks in Database seeder
- Moved the function to the attribute model where it belongs which allows testability.

Case Change Capability (CSV Import and Form)
- CSV Import and view Case Changes of `attribute_value`
- Store attribute even when just case is different.
- Add getAttributeValueByAttributeId() to assist in comparing the value
- Corrected Capitalization in File Handling Logic

CSV Import Attribute Link Deletion Capability
- Validation checks bypass magic word cells.
- Delete the attribute link for an item if the CSV contains `_DELETE_`
- Added calls to deleteOrphanedValues()
- Items CSV Import Attribute Delete
- Exclude the itemId in the check to see if the barcode number exists

Error Checking and Reporting Improvements
- Fail the import if an invalid stock location is found in the CSV
- Return false if deleteAttributeLinks fails
- Match sanitization of description field to Form submission import
- Fold errors into result and return value
- Populated $allowedStockLocations before sending it to the validation function
- Added logic to not ignore failed saveItemAttributes calls
- Add error checking to failed row insert
- Reworked &= to && logic so that it short-circuits the function call after if success is already false.
- Add transaction to storeCSVAttributeValue function to prevent deleting the attribute links before confirming the new value successfully saved.
- Modified generate_message in Db_log.php to be defensive.

Attribute Improvements
- Move ATTRIBUTE_VALUE_TYPES to the helper
- Normalize AttributeId in saveAttributeLink()
- normalize itemId in saveAttributeLink()
- Account for '0' in column values for allow_alt_description
- Remove duplicate saveAttributeValue call
- Correct return value of function
- Like other save_value() functions, the location_data variable is passed by reference.
- Unlike other save_value() functions, the location_data variable is not being updated with the primary key id.
- Added updateAttributeValue() function as part of logic fix.
- Added attribute_helper.php
- Simplified logic to store attribute values

---------

Signed-off-by: objec <objecttothis@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-09 11:13:22 +04:00
jekkos
e70395bb85 Fix: Improve allowedHostnames .env configuration and fail-fast in production (#4482)
* Fix: Improve allowedHostnames .env configuration and fail-fast in production

Addresses GitHub issue #4480: .env app.allowedHostnames does not work as intended

## Problem
- CodeIgniter 4 cannot override array properties from .env
- Setting app.allowedHostnames.0, app.allowedHostnames.1 did NOT populate the array
- Application always fell back to 'localhost' silently in production
- Host header injection protection was effectively disabled

## Solution
1. Support comma-separated .env values: app.allowedHostnames = 'domain1.com,domain2.com'
2. Fail explicitly in production if not configured (throws RuntimeException)
3. Allow localhost fallback in development/testing with ERROR-level logging
4. Update documentation with clear setup instructions

## Changes
- app/Config/App.php: Parse comma-separated .env values, fail in production
- .env.example: Update format documentation
- INSTALL.md: Add prominent security section
- tests/Config/AppTest.php: Comprehensive tests for new behavior

Fixes #4480
Related: GHSA-jchf-7hr6-h4f3
---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-08 23:07:45 +02:00
jekkos
7f9321eca0 fix: Handle empty database on fresh install (#4467)
* fix: Handle empty database on fresh install
* feat: Add migration progress bar with jQuery AJAX

- Session.php: Switch to file-based sessions when migrations table doesn't exist
- OSPOS.php: Catch DatabaseException when config table missing, set defaults
- MY_Migration.php: Handle database connection failures gracefully
- Load_config.php: Set default language settings when config empty
---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-04-08 20:19:25 +00:00
jekkos
71056d9b03 fix: Tax Rate form not loading due to router service failure (#4479)
The get_tax_rates_data_row() function in tax_helper.php was calling
service('router') without handling cases where the router service is
unavailable, causing the form modal to fail to open.

This fix adds a fallback to 'taxes' controller name when router service
returns null or fails.

Also adds missing 'id' field in postSave() JSON response for proper
row highlighting after save operations.

Fixes #4477

Co-authored-by: Ollama <ollama@steganos.dev>
Co-authored-by: odiea <odiea@users.noreply.github.com>
2026-04-08 15:34:37 +02:00
Ollama
e17944d883 fix: address all review comments and restore issue template version update
- Add issue template version update back with correct 'OpenSourcePOS' casing
- Fix version list inconsistency (add 3.3.8 to feature_request.yml, align with bug report.yml)
- Fix changelog special characters issue by using temp file instead of inline sed
- Keep versions in sync between both templates
2026-04-08 11:01:11 +00:00
Ollama
0ac427b2b1 fix: address review comments
- Update [unreleased] changelog link to start from new version
- Remove misleading notes about automatic version updates from issue templates
  (release workflow no longer auto-updates template version lists)
2026-04-08 11:01:11 +00:00
Ollama
3038f83a4a refactor: simplify release workflow to version bump only
- Remove build steps (handled by build-release.yml on push)
- Remove tag creation (create tag from unstable release later)
- Remove draft release creation
- Remove SECURITY.md and issue template updates
- Keep version bumps in: App.php, package.json, docker-compose.nginx.yml, README.md, CHANGELOG.md

Workflow now:
1. Bumps version in source files
2. Commits and pushes to master
3. build-release.yml picks up the push and creates unstable release
2026-04-08 11:01:11 +00:00
Ollama
75f6ce3140 feat: add release workflow with automated version bumping
- Add workflow_dispatch triggered release.yml with major/minor/patch options
- Auto-update version in App.php, package.json, docker-compose.nginx.yml
- Auto-update README.md and SECURITY.md version references
- Auto-update issue templates with new version dropdowns
- Generate CHANGELOG.md from git commits since last version
- Build distribution archives and create draft GitHub release
- Add draft_only input for testing without pushing changes

Issue templates improvements:
- Remove deprecated update-issue-templates.yml cron workflow
- Reorganize with clear sections and visual hierarchy
- Add emojis and improve placeholder text with examples
- Add new fields: logs, screenshots, acceptance criteria
- Add note about automatic version updates
2026-04-08 11:01:11 +00:00
objecttothis
ce7a3ce341 Translated using Weblate (Swahili (Tanzania))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/sw_TZ/
2026-04-07 20:47:37 +02:00
objecttothis
d99d2855ec Translated using Weblate (Swahili (sw_KE))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/sw_KE/
2026-04-07 20:47:37 +02:00
objecttothis
96b4b24d9b Translated using Weblate (Kurdish (Central))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/ckb/
2026-04-07 20:47:37 +02:00
objecttothis
871231e406 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/uk/
2026-04-07 20:47:37 +02:00
objecttothis
e62477ed4e Translated using Weblate (Tamil)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/ta/
2026-04-07 20:47:37 +02:00
objecttothis
2a0997f267 Translated using Weblate (Bosnian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/bs/
2026-04-07 20:47:37 +02:00
objecttothis
1ca8effe08 Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/zh_Hant/
2026-04-07 20:47:37 +02:00
objecttothis
ed2c975ad5 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/zh_Hans/
2026-04-07 20:47:37 +02:00
objecttothis
403feed3e5 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/vi/
2026-04-07 20:47:37 +02:00
objecttothis
7f6f36210c Translated using Weblate (Turkish)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/tr/
2026-04-07 20:47:37 +02:00
objecttothis
1121ced532 Translated using Weblate (Tagalog)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/tl/
2026-04-07 20:47:37 +02:00
objecttothis
632a18212d Translated using Weblate (Swedish)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/sv/
2026-04-07 20:47:37 +02:00
objecttothis
3208f15244 Translated using Weblate (Russian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/ru/
2026-04-07 20:47:37 +02:00
objecttothis
079b809622 Translated using Weblate (Romanian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/ro/
2026-04-07 20:47:37 +02:00
objecttothis
d685e09c29 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/pt_BR/
2026-04-07 20:47:37 +02:00
objecttothis
149c27d60f Translated using Weblate (Polish)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/pl/
2026-04-07 20:47:37 +02:00
objecttothis
57b7705cd4 Translated using Weblate (Dutch)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/nl/
2026-04-07 20:47:37 +02:00
objecttothis
e8951422c0 Translated using Weblate (Dutch (Belgium))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/nl_BE/
2026-04-07 20:47:37 +02:00
objecttothis
8afc57fcf4 Translated using Weblate (Lao)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/lo/
2026-04-07 20:47:37 +02:00
objecttothis
7af64a9a21 Translated using Weblate (Khmer (Central))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/km/
2026-04-07 20:47:37 +02:00
objecttothis
46d5781498 Translated using Weblate (Italian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/it/
2026-04-07 20:47:37 +02:00
objecttothis
66b61c0554 Translated using Weblate (Indonesian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/id/
2026-04-07 20:47:37 +02:00
objecttothis
6b97131c48 Translated using Weblate (Hungarian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/hu/
2026-04-07 20:47:37 +02:00
objecttothis
a4c19a3c2c Translated using Weblate (Croatian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/hr/
2026-04-07 20:47:37 +02:00
objecttothis
7ca8c9561a Translated using Weblate (Hebrew)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/he/
2026-04-07 20:47:37 +02:00
objecttothis
4fac5d9198 Translated using Weblate (French)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/fr/
2026-04-07 20:47:37 +02:00
objecttothis
221995b6db Translated using Weblate (Persian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/fa/
2026-04-07 20:47:37 +02:00
objecttothis
91dbe5b869 Translated using Weblate (Spanish (Mexico))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/es_MX/
2026-04-07 20:47:37 +02:00
objecttothis
afd908327b Translated using Weblate (Spanish)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/es/
2026-04-07 20:47:37 +02:00
objecttothis
cfde66481d Translated using Weblate (English (United Kingdom))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/en_GB/
2026-04-07 20:47:37 +02:00
objecttothis
80f00c8552 Translated using Weblate (German)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/de/
2026-04-07 20:47:37 +02:00
objecttothis
dbdf4db4fb Translated using Weblate (German (Switzerland))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/de_CH/
2026-04-07 20:47:37 +02:00
objecttothis
64004db271 Translated using Weblate (Danish)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/da/
2026-04-07 20:47:37 +02:00
objecttothis
7f20a5dd4c Translated using Weblate (Czech)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/cs/
2026-04-07 20:47:37 +02:00
objecttothis
d7a276b488 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/bg/
2026-04-07 20:47:37 +02:00
objecttothis
57dbe43313 Translated using Weblate (Azerbaijani)
Currently translated at 87.5% (7 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/az/
2026-04-07 20:47:37 +02:00
objecttothis
6f1c39d99e Translated using Weblate (Arabic (ar_LB))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/ar_LB/
2026-04-07 20:47:37 +02:00
objecttothis
45902caa67 Translated using Weblate (Arabic (Egypt))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/ar_EG/
2026-04-07 20:47:37 +02:00
objecttothis
1fe865a100 Translated using Weblate (English)
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/en/
2026-04-07 20:47:37 +02:00
Ollama
90da63cb13 fix(security): prevent SQL injection in tax controller sort columns
Add sanitizeSortColumn() validation to prevent SQL injection in the
sort parameter of search() methods in tax-related controllers.

Vulnerable controllers:
- Taxes.php: sort column was passed directly to model
- Tax_categories.php: sort column was passed directly to model
- Tax_codes.php: sort column was passed directly to model
- Tax_jurisdictions.php: sort column was passed directly to model

Fix: Use sanitizeSortColumn() to validate sort column against
allowed headers, defaulting to primary key if invalid.
2026-04-06 18:37:07 +00:00
Ollama
8da4aff262 fix(security): prevent command injection in sendmail path configuration
Add validation for the mailpath POST parameter to prevent command injection
attacks. The path is validated to only allow alphanumeric characters,
underscores, dashes, forward slashes, and dots.

- Required mailpath when protocol is "sendmail"
- Validates format for all non-empty mailpath values
- Blocks common injection vectors: ; | & ` $() spaces newlines
- Added mailpath_invalid translation to all 43 language files
- Simplified validation logic to avoid redundant conditions

Files changed:
- app/Controllers/Config.php: Add regex validation with protocol check
- app/Language/*/Config.php: Add mailpath_invalid error message (43 languages)
- tests/Controllers/ConfigTest.php: Unit tests for validation
2026-04-06 18:37:07 +00:00
Ollama
0e9f4a998d fix(ci): replace / with _ in branch names for Docker tags
Docker image tags cannot contain / characters. Replace them with _
to ensure valid tag names when building from feature branches.
2026-04-06 10:51:56 +02:00
Nozomu Sasaki (Paul)
85c7ce2da4 Fix negative price/quantity/discount validation (GHSA-wv3j-pp8r-7q43) (#4450)
* Fix business logic vulnerability allowing negative sale totals (GHSA-wv3j-pp8r-7q43)

Add server-side validation in postEditItem() to reject negative prices,
quantities, and discounts, as well as percentage discounts exceeding 100%
and fixed discounts exceeding the item total. Also block sale completion
with negative totals in non-return mode to prevent fraud/theft.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: exempt return mode from negative quantity validation

Return mode legitimately stores items with negative quantities.
The quantity validation now skips the non-negative check in return mode,
consistent with the existing return mode exemption in postComplete().
Also use abs() for fixed discount comparison to handle return quantities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: use $rules + validate() pattern per review feedback

Address review comments from jekkos on PR #4450:

1. Use CI4 $rules variable with custom non_negative_decimal validation
   rule instead of manual if-checks for price/discount validation.

2. Add validation error strings to all 44 non-English language files
   (English fallback values used until translations are contributed).

3. Use validate() method with $messages array for localized error
   display, maintaining the existing controller pattern.

Additional improvements:
- Add non_negative_decimal rule to OSPOSRules.php (leverages
  parse_decimals() for locale-aware decimal parsing)
- Preserve manual checks for business logic (return mode quantity
  exemption, discount bounds via bccomp)
- Fix PHP 8.1+ compatibility: avoid passing method return to reset()
- Explicit empty discount handling for bc-math safety

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix: rename to nonNegativeDecimal (PSR), clear non-English translation strings

- Rename validation rule method non_negative_decimal → nonNegativeDecimal in
  OSPOSRules.php and all $rules/$messages references in Sales.php (PSR naming
  per @objecttothis review)
- Replace English fallback text with "" in 43 non-English language files so
  CI4 falls back to the base language string; weblate will handle translations
  (per @jekkos and @objecttothis agreement)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Paul <morimori-dev@github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-04-03 14:49:42 +04:00
jekkos
e723e2ddf4 Disable opencode workflow + run docker build 2026-04-02 19:56:54 +02:00
Ollama
71eb8de7fe feat: Improve migration UX on login page
- Show 'Database Migration Required' heading instead of welcome message when migrations pending
- Add warning alert explaining credentials needed to authorize migration
- Change button text from 'Go' to 'Migrate' during migration flow
- Add language strings for clear migration messaging

This makes it clear to the user that they are performing a migration
authorization step (not a regular login), and will need to re-authenticate
after migrations complete.
2026-04-02 06:50:38 +00:00
Ollama
9d5019e12e fix: Use file-based session until database is migrated
- Prevents circular dependency where login requires session, but session requires database table created by migrations
- Fresh installs: use file session until migrations run, then switch to database session
- Upgrades: use file session during migration, then switch to database session
- Simplified: removed DDL from Load_config, migrations remain source of truth

Fixes: Sessions table missing on fresh install prevents login

Addresses CodeRabbit feedback:
- Remove duplicate session table DDL (migrations are source of truth)
- Remove MySQL-specific information_schema query
- Use proper session config cloning to avoid modifying shared config
2026-04-02 06:50:38 +00:00
Ollama
56670271d6 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-04-01 16:46:03 +00:00
Ollama
cef103445e 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-04-01 16:46:03 +00:00
Ollama
68e9a56632 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-04-01 16:46:03 +00:00
Ollama
ba05536317 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-04-01 16:46:03 +00:00
Ollama
f74f286a51 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-04-01 16:46:03 +00:00
Ollama
7180ec33e8 Add Calendar.php translations for missing languages
- es-MX: Spanish (Mexico) calendar translations
- nb: Norwegian Bokmål calendar translations
- he: Hebrew calendar translations
- ml: Malayalam calendar translations
- tl: Tagalog calendar translations
- ar-EG: Arabic (Egypt) calendar translations
- ka, lo, ur: Empty placeholders (cannot translate these languages)
2026-04-01 13:01:54 +00:00
Ollama
496c8a8262 Remove English fallbacks from non-English translations
Use empty strings for sale_not_found in Khmer, Malayalam, and Urdu
as these languages cannot be translated by this model.
2026-04-01 13:01:54 +00:00
Ollama
493d9cc9c1 Fix translation issues from code review
- Use 'Bénéfice' in French Reports.php for consistency with 'profit' key
- Fix Italian grammar: 'Il numero di telefono è richiesto'
- Use 'Responsabile' for 'manager' in Italian Common.php
- Add 'sale_not_found' translation key to missing languages (km, ml, ta, ur)
- Tamil (ta) gets proper translation, others get English fallback
2026-04-01 13:01:54 +00:00
Ollama
f761e1464f Translate missing strings in multiple languages
- Add Calendar.php translation file for es-ES, nl-NL, de-DE
- Fill empty string translations in Common.php for de-DE, nl-NL, fr, it, pt-BR
- Translate UBL invoice strings (ubl_invoice, download_ubl, ubl_generation_failed) in Sales.php
- Add toggle_cost_and_profit in Reports.php for all languages
- Translate error_deleting_admin and error_updating_admin in Employees.php
- Translate csv_import_invalid_location error message in Items.php
- Update various missing translations for administrator, clerk, manager, dashboard, etc.

Languages updated: Spanish (es-ES), Dutch (nl-NL), German (de-DE), French (fr), Italian (it), Portuguese (pt-BR)
2026-04-01 13:01:54 +00:00
Ollama
a5bbb2bcc5 fix: Remove redundant clear_mode() calls
clear_all() already calls clear_mode() internally, so the separate
clear_mode() calls were redundant.
2026-04-01 07:24:23 +00:00
Ollama
92ec321d08 fix: Clear sale session after completing sale
The clear_all() calls in postComplete() were placed after return
statements, making them unreachable dead code. This caused the
completed sale to remain in the session and appear in the Register
when navigating back.

The fix moves clear_all() and clear_mode() calls before the return
statements so they are actually executed, properly clearing the sale
cart, customer, and payments from the session after sale completion.

This fixes the regression reported by @odiea where users had to
manually cancel sales after each transaction.
2026-04-01 07:24:23 +00:00
dependabot[bot]
e046e74c79 Bump picomatch from 2.3.1 to 2.3.2 (#4451)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

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-30 12:38:07 +04:00
khao_lek
e0cd0f6129 Translated using Weblate (Thai)
Currently translated at 100.0% (146 of 146 strings)

Translation: opensourcepos/reports
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/reports/th/
2026-03-29 11:10:04 +02:00
khao_lek
3b102adf3f Translated using Weblate (Thai)
Currently translated at 100.0% (47 of 47 strings)

Translation: opensourcepos/expenses
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/expenses/th/
2026-03-29 11:10:03 +02:00
khao_lek
260358d611 Translated using Weblate (Thai)
Currently translated at 100.0% (146 of 146 strings)

Translation: opensourcepos/reports
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/reports/th/
2026-03-28 06:04:39 +01:00
khao_lek
e615200466 Translated using Weblate (Thai)
Currently translated at 100.0% (68 of 68 strings)

Translation: opensourcepos/giftcards
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/giftcards/th/
2026-03-28 06:04:39 +01:00
khao_lek
56cead478a Translated using Weblate (Thai)
Currently translated at 100.0% (118 of 118 strings)

Translation: opensourcepos/items
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/items/th/
2026-03-28 06:04:39 +01:00
khao_lek
7030f6bac3 Translated using Weblate (Thai)
Currently translated at 100.0% (43 of 43 strings)

Translation: opensourcepos/employees
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/employees/th/
2026-03-28 06:04:38 +01:00
yakub3k
299f62669a Translated using Weblate (Polish)
Currently translated at 13.6% (20 of 146 strings)

Translation: opensourcepos/reports
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/reports/pl/
2026-03-27 14:29:27 +01:00
yakub3k
072865620a Translated using Weblate (Polish)
Currently translated at 100.0% (19 of 19 strings)

Translation: opensourcepos/expenses_categories
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/expenses_categories/pl/
2026-03-27 14:29:27 +01:00
yakub3k
3bbd4c4c95 Translated using Weblate (Polish)
Currently translated at 29.4% (20 of 68 strings)

Translation: opensourcepos/giftcards
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/giftcards/pl/
2026-03-27 14:29:27 +01:00
yakub3k
0253bf85b8 Translated using Weblate (Polish)
Currently translated at 13.5% (16 of 118 strings)

Translation: opensourcepos/items
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/items/pl/
2026-03-27 14:29:26 +01:00
yakub3k
92c1be8bb1 Translated using Weblate (Polish)
Currently translated at 26.0% (12 of 46 strings)

Translation: opensourcepos/cashups
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/cashups/pl/
2026-03-27 14:29:26 +01:00
yakub3k
23829eab35 Translated using Weblate (Polish)
Currently translated at 12.7% (6 of 47 strings)

Translation: opensourcepos/expenses
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/expenses/pl/
2026-03-27 14:29:25 +01:00
yakub3k
c81c6506cb Translated using Weblate (Polish)
Currently translated at 17.1% (38 of 222 strings)

Translation: opensourcepos/sales
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/sales/pl/
2026-03-27 14:29:25 +01:00
yakub3k
840d9ccc81 Translated using Weblate (Polish)
Currently translated at 9.5% (2 of 21 strings)

Translation: opensourcepos/suppliers
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/suppliers/pl/
2026-03-27 14:29:25 +01:00
yakub3k
e763ee2acc Translated using Weblate (Polish)
Currently translated at 14.6% (48 of 327 strings)

Translation: opensourcepos/config
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/config/pl/
2026-03-27 14:29:25 +01:00
yakub3k
8ef109efbc Translated using Weblate (Polish)
Currently translated at 12.3% (18 of 146 strings)

Translation: opensourcepos/reports
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/reports/pl/
2026-03-27 13:05:14 +01:00
yakub3k
9a544096c2 Translated using Weblate (Polish)
Currently translated at 94.7% (18 of 19 strings)

Translation: opensourcepos/expenses_categories
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/expenses_categories/pl/
2026-03-27 13:05:08 +01:00
yakub3k
3e4ac0b24d Translated using Weblate (Polish)
Currently translated at 4.4% (3 of 68 strings)

Translation: opensourcepos/giftcards
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/giftcards/pl/
2026-03-27 13:05:01 +01:00
yakub3k
3c9c592ca3 Translated using Weblate (Polish)
Currently translated at 13.1% (5 of 38 strings)

Translation: opensourcepos/item_kits
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/item_kits/pl/
2026-03-27 13:04:56 +01:00
yakub3k
a4d8bedbf3 Translated using Weblate (Polish)
Currently translated at 7.6% (9 of 118 strings)

Translation: opensourcepos/items
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/items/pl/
2026-03-27 13:04:53 +01:00
yakub3k
c4304fd0a9 Translated using Weblate (Polish)
Currently translated at 16.2% (36 of 222 strings)

Translation: opensourcepos/sales
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/sales/pl/
2026-03-27 13:04:48 +01:00
yakub3k
44fe2c087a Translated using Weblate (Polish)
Currently translated at 7.5% (4 of 53 strings)

Translation: opensourcepos/customers
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/customers/pl/
2026-03-27 13:04:42 +01:00
yakub3k
985c1c55ce Translated using Weblate (Polish)
Currently translated at 21.0% (4 of 19 strings)

Translation: opensourcepos/expenses_categories
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/expenses_categories/pl/
2026-03-27 12:48:01 +01:00
yakub3k
8029e5538f Translated using Weblate (Polish)
Currently translated at 15.7% (3 of 19 strings)

Translation: opensourcepos/expenses_categories
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/expenses_categories/pl/
2026-03-27 12:46:33 +01:00
yakub3k
1a7683a8ac Translated using Weblate (Polish)
Currently translated at 10.6% (5 of 47 strings)

Translation: opensourcepos/expenses
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/expenses/pl/
2026-03-27 12:46:33 +01: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
Ollama
9a2b308647 Sync language files (#3468)
- Add csv_import_invalid_location to Items.php for CSV import validation
- Add error_deleting_admin and error_updating_admin to Employees.php for admin protection messages

Strings added with empty values so they fallback to English and show as untranslated in Weblate.
2026-03-09 07:45:19 +01:00
Ollama
1f55d96580 Fix mass assignment vulnerability in bulk edit (GHSA-49mq-h2g4-grr9)
The bulk edit function iterated over all $_POST keys without a whitelist,
allowing authenticated users to inject arbitrary database columns (e.g.,
cost_price, deleted, item_type) into the update query. This bypassed
CodeIgniter 4's $allowedFields protection since Query Builder was used
directly.

Fix: Add ALLOWED_BULK_EDIT_FIELDS constant to Item model defining the
explicit whitelist of fields that can be bulk-updated. Use this constant
in the controller instead of iterating over $_POST directly.

Fields allowed: name, category, supplier_id, cost_price, unit_price,
reorder_level, description, allow_alt_description, is_serialized

Security impact: High (CVSS 8.1) - Could allow price manipulation and
data integrity violations.
2026-03-08 22:49:12 +01:00
Ollama
b2fadea44a Fix broken SQL injection fix - use havingLike() instead of having() with named params
The previous SQL injection fix (GHSA-hmjv-wm3j-pfhw) used named parameter
syntax :search: with having(), but CodeIgniter 4's having() method does
not support named parameters. This caused the query to fail.

The fix uses havingLike() which properly:
- Escapes the search value to prevent SQL injection
- Handles the LIKE clause construction internally (wraps value with %)
- Works correctly with HAVING clauses for aggregated columns

This maintains the security fix while actually working on CI4.
2026-03-08 22:48:43 +01:00
Ollama
0fdb3ba37b Fix payment type becoming null when editing sales
When localization uses dot (.) as thousands separator (e.g., it_IT, es_ES, pt_PT),
the payment_amount value was displayed as raw float (e.g., '10.50') but parsed
using parse_decimals() which expects locale-formatted numbers.

In these locales, '.' is thousands separator and ',' is decimal separator.
parse_decimals('10.50') would return false, causing the condition
 != 0 to evaluate incorrectly (false == 0 in PHP),
resulting in the payment being deleted instead of updated.

Fix: Use to_currency_no_money() to format payment_amount and cash_refund
values according to locale before displaying in the form, so parse_decimals()
can correctly parse them on submission.
2026-03-08 22:34:47 +01:00
jekkos
d7b2264ac1 Fix: Preserve CHECKBOX attribute state when adding attributes (#4385)
Modified definition_values() function in app/Views/attributes/item.php to properly handle checkbox attributes.

The issue was that checkbox attributes have two input elements (hidden and checkbox) with the same name pattern. When collecting attribute values during the refresh operation, both inputs were being processed, with the hidden input potentially overwriting the checkbox state.

Changes:
- Skip hidden inputs that have a corresponding checkbox input
- For checkbox inputs, explicitly capture the checked state using prop('checked')
- Convert checked state to '1' or '0' for consistency

This ensures that when adding another attribute to an item, existing checkbox states are preserved correctly.
2026-03-08 22:31:02 +01:00
Ollama
a229bf6031 Fix stored XSS vulnerabilities in employee permissions and customer data
1. Stock Location XSS (GHSA-7hg5-68rx-xpmg):
   - Stock location names were rendered unescaped in employee form
   - Malicious stock locations could contain XSS payloads that execute
     when viewing employee permissions
   - Fixed by adding esc() to permission display in employees/form.php

2. Customer Name XSS (GHSA-hcfr-9hfv-mcwp):
   - Bootstrap-table columns had escape disabled for customer_name,
     email, phone_number, and note fields
   - Malicious customer names could execute XSS in Daily Sales view
   - Fixed by removing user-controlled fields from escape exception list
   - Only 'edit', 'messages', and 'item_pic' remain in exception list
     (these contain safe server-generated HTML)

Both vulnerabilities allow authenticated attackers with basic permissions
to inject JavaScript that executes in admin/other user sessions.
2026-03-08 18:42:30 +01:00
Ollama
977fa5647b Fix stored XSS vulnerability in item descriptions
GHSA-q58g-gg7v-f9rf: Stored XSS via Item Description

Security Impact:
- Authenticated users with item management permission can inject XSS payloads
- Payloads execute in POS register view (sales and receivings)
- Can steal session cookies, perform CSRF attacks, or compromise POS operations

Root Cause:
1. Input: Items.php:614 accepts description without sanitization
2. Output: register.php:255 and receiving.php:220 echo description without escaping

Fix Applied:
- Input sanitization: Added FILTER_SANITIZE_FULL_SPECIAL_CHARS to description POST
- Output escaping: Added esc() wrapper when echoing item descriptions
- Defense-in-depth approach: sanitize on input, escape on output

Files Changed:
- app/Controllers/Items.php - Sanitize description on save
- app/Views/sales/register.php - Escape description on display
- app/Views/receivings/receiving.php - Escape description on display

Testing:
- XSS payloads like '<script>alert(1)</script>' are now sanitized on input
- Any existing malicious descriptions are escaped on output
- Does not break legitimate descriptions with special characters
2026-03-07 20:51:48 +01:00
Ollama
52b0a83190 Fix SQL injection in custom attribute search
Parameterize LIKE queries in HAVING clause to prevent SQL injection
when search_custom filter is enabled. Also sanitize search parameter
input at controller level for defense-in-depth.

Fixes vulnerability where user input was directly interpolated into
SQL queries without sanitization.
2026-03-07 19:10:42 +01:00
jekkos
f25a0f5b09 Refactor: Move ADMIN_MODULES to constants, rename methods to camelCase
- Move admin modules list from is_admin method to ADMIN_MODULES constant
- Rename is_admin() to isAdmin() following CodeIgniter naming conventions
- Rename can_modify_employee() to canModifyEmployee() following conventions
- Update all callers in Employees controller and tests
2026-03-06 17:25:25 +01:00
jekkos
f0f288797a Add migration to fix existing image filenames with spaces (#4372)
This migration will:
- Scan all items for filenames containing spaces
- Rename both original and thumbnail files on the filesystem
- Update database records with sanitized filenames
- Only process files that actually exist on the filesystem
2026-03-06 17:09:52 +01:00
jekkos
63083a0946 Fix: Sanitize image filenames to prevent thumbnail display issues (#4372)
When uploading item images with filenames containing spaces, the thumbnails fail to load due to Apache mod_rewrite rejecting URLs with spaces.

Changes:
- Modified upload_image() method to sanitize filenames by replacing spaces and special characters with underscores
- Uses regex to keep only alphanumeric, underscores, hyphens, and periods
- Preserves original filename in 'orig_name' field for reference
- Fixes issue where thumbnail URLs would fail with 'AH10411: Rewritten query string contains control characters or spaces'

Example: 'banana marsmellow.jpg' becomes 'banana_marsmellow.jpg'

Fixes: #4372
2026-03-06 17:09:52 +01:00
jekkos
3a33098776 Fix: Handle image filenames with spaces in thumbnails
- URL-encode filenames when constructing image/thumbnail URLs
- Decode filename parameter in getPicThumb() controller
- Prevents Apache AH10411 error with spaces in rewritten URLs

Fixes #4372
2026-03-06 17:09:52 +01:00
jekkos
ca6a1b35af Add row-level authorization to password change endpoints (#4401)
* fix(security): add row-level authorization to password change endpoints

- Prevents non-admin users from viewing other users' password forms
- Prevents non-admin users from changing other users' passwords
- Uses can_modify_employee() check consistent with Employees controller fix
- Addresses BOLA vulnerability in Home controller (GHSA-q58g-gg7v-f9rf)

* test(security): add BOLA authorization tests for Home controller

- Test non-admin cannot view/change admin password
- Test user can view/change own password
- Test admin can view/change any password
- Test default employee_id uses current user
- Add JUnit test result upload to CI workflow

* refactor: apply PSR-12 naming and add DEFAULT_EMPLOYEE_ID constant

- Add DEFAULT_EMPLOYEE_ID constant to Constants.php
- Rename variables to follow PSR-12 camelCase convention
- Use ternary for default employee ID assignment

* refactor: use NEW_ENTRY constant instead of adding DEFAULT_EMPLOYEE_ID

Reuse existing NEW_ENTRY constant for default employee ID parameter.
Avoids adding redundant constants to Constants.php with same value (-1).

---------

Co-authored-by: jekkos <jeroen@steganos.dev>
2026-03-06 17:08:36 +01:00
jekkos
418580a52d Fix second-order SQL injection in currency_symbol config (#4390)
* Fix second-order SQL injection in currency_symbol config

The currency_symbol value was concatenated directly into SQL queries
without proper escaping, allowing SQL injection attacks via the
Summary Discounts report.

Changes:
- Use $this->db->escape() in Summary_discounts::getData() to properly
  escape the currency symbol value before concatenation
- Add htmlspecialchars() validation in Config::postSaveLocale() to
  sanitize the input at storage time
- Add unit tests to verify escaping of malicious inputs

Fixes SQL injection vulnerability described in bug report where
attackers with config permissions could inject arbitrary SQL through
the currency_symbol field.

* Update test to use CIUnitTestCase for consistency

Per code review feedback, updated test to extend CIUnitTestCase
instead of PHPUnit TestCase to maintain consistency with other
tests in the codebase.

---------

Co-authored-by: Ollama <ollama@steganos.dev>
2026-03-06 17:01:38 +01:00
jekkos
31d25e06dc fix(security): whitelist and validate invoice template types (#4393)
- Add whitelist validation for invoice_type to prevent path traversal and LFI
- Validate invoice_type against allowed values in Sale_lib
- Sanitize invoice_type input in Config controller before saving
- Default to 'invoice' template for invalid types

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 21:54:49 +01:00
jekkos
19eb43270a Fix broken object-level authorization in Employees controller (CVE-worthy) (#4391)
- Non-admin employees can no longer view/modify admin accounts
- Non-admin employees can no longer delete admin accounts
- Non-admin employees can only grant permissions they themselves have
- Added is_admin() and can_modify_employee() methods to Employee model
- Prevents privilege escalation via permission grants

Add tests for BOLA fix and permission delegation

- EmployeeTest: Unit tests for is_admin() and can_modify_employee() methods
- EmployeesControllerTest: Test cases for authorization checks (integration tests require DB)
- ReportsControllerTest: Test validating the constructor redirect fix pattern

Fix return type error in Employees controller

Use $this->response->setJSON() instead of echo json_encode() + return
to properly satisfy the ResponseInterface return type.
2026-03-05 19:46:39 +01:00
jekkos
df4549bb0b Fix Docker image upload by replacing slashes in TAG 2026-03-05 14:46:45 +00:00
jekkos
bdc965be23 Fix: Refresh session language for employee after update. (#4245) 2026-03-04 22:43:52 +01:00
Lucas Lyimo
5c8905aa1b Language Array Key Typo Fix (#4371)
* Fix typo in stock location translation

* Fix typo in stock location key

* Fix typo in Language Receivings files stock_location

* Add Swahili-TZ Language Files

* Add Swahili-KE Language Files
2026-03-04 22:06:17 +01:00
jekkos
690f43578d Use Content-Type application/json for AJAX responses (#4357)
Complete Content-Type application/json fix for all AJAX responses

- Add missing return statements to all ->response->setJSON() calls
- Fix Items.php method calls from JSON() to setJSON()
- Convert echo statements to proper JSON responses
- Ensure consistent Content-Type headers across all controllers
- Fix 46+ instances across 12 controller files
- Change Config.php methods to : ResponseInterface (all return setJSON only):
  - postSaveRewards(), postSaveBarcode(), postSaveReceipt()
  - postSaveInvoice(), postRemoveLogo()
  - Update PHPDoc @return tags

- Change Receivings.php _reload() to : string (only returns view)
- Change Receivings.php methods to : string (all return _reload()):
  - getIndex(), postSelectSupplier(), postChangeMode(), postAdd()
  - postEditItem(), getDeleteItem(), getRemoveSupplier()
  - postComplete(), postRequisitionComplete(), getReceipt(), postCancelReceiving()
- Change postSave() to : ResponseInterface (returns setJSON)
- Update all PHPDoc @return tags

Fix XSS vulnerabilities in sales templates, login, and config pages

This commit addresses 5 XSS vulnerabilities by adding proper escaping
to all user-controlled configuration values in HTML contexts.

Fixed Files:
- app/Views/sales/invoice.php: Escaped company_logo (URL context) and company (HTML)
- app/Views/sales/work_order.php: Escaped company_logo (URL context)
- app/Views/sales/receipt_email.php: Added file path validation and escaping for logo
- app/Views/login.php: Escaped all config values in title, logo src, and alt
- app/Views/configs/info_config.php: Escaped company_logo (URL context)

Security Impact:
- Prevents stored XSS attacks if configuration is compromised
- Defense-in-depth principle applied to administrative interfaces
- Follows OWASP best practices for output encoding

Testing:
- Verified no script execution with XSS payloads in config values
- Confirmed proper escaping in HTML, URL, and file contexts
- All templates render correctly with valid configuration

Severity: High (4 files), Medium-High (1 file)
CVSS Score: ~6.1
CWE: CWE-79 (Improper Neutralization of Input During Web Page Generation)

Fix critical password validation bypass and add unit tests

This commit addresses a critical security vulnerability where the password
minimum length check was performed on the HASHED password (always 60
characters for bcrypt) instead of the actual password before hashing.

Vulnerability Details:
- Original code: strlen($employee_data['password']) >= 8
- This compared the hash length (always 60) instead of raw password
- Impact: Users could set 1-character passwords like "a"
- Severity: Critical (enables brute force attacks on weak passwords)
- CVE-like issue: CWE-307 (Improper Restriction of Excessive Authentication Attempts)

Fix Applied:
- Validate password length BEFORE hashing
- Clear error message when password is too short
- Added unit tests to verify minimum length enforcement
- Regression test to prevent future vulnerability re-introduction

Test Coverage:
- testPasswordMinLength_Rejects7Characters: Verify 7 chars rejected
- testPasswordMinLength_Accepts8Characters: Verify 8 chars accepted
- testPasswordMinLength_RejectsEmptyString: Verify empty rejected
- testPasswordMinLength_RejectsWhitespaceOnly: Verify whitespace rejected
- testPasswordMinLength_AcceptsSpecialCharacters: Verify special chars OK
- testPasswordMinLength_RejectsPreviousBehavior: Regression test for bug

Files Modified:
- app/Controllers/Home.php: Fixed password validation logic
- tests/Controllers/HomeTest.php: Added comprehensive unit tests

Security Impact:
- Enforces 8-character minimum password policy
- Prevents extremely weak passwords that facilitate brute-force attacks
- Critical for credential security and user account protection

Breaking Changes:
- Users with passwords < 8 characters will need to reset their password
- This is the intended security improvement

Severity: Critical
CVSS Score: ~7.5
CWE: CWE-305 (Authentication Bypass by Primary Weakness), CWE-307

Add GitHub Actions workflow to run PHPUnit tests

Move business logic from views to controllers for better separation of concerns

- Move logo URL computation from info_config view to Config::getIndex()
- Move image base64 encoding from receipt_email view to Sales controller
- Improves separation of concerns by keeping business logic in controllers
- Simplifies view templates to only handle presentation

Fix XSS vulnerabilities in report views - escape user-controllable summary data and labels

Fix base64 encoding URL issue in delete payment - properly URL encode base64 string

Fix remaining return type declarations for Sales controller

Fixed additional methods that call _reload():
- postAdd() - returns _reload($data)
- postAddPayment() - returns _reload($data)
- postEditItem() - returns _reload($data)
- postSuspend() - returns _reload($data)
- postSetPaymentType() - returns _reload()

All methods now return ResponseInterface|string to match _reload() signature.
This resolves PHP TypeError errors.
2026-03-04 21:42:35 +01:00
jekkos
0858a1c23c Fix permission bypass in Reports submodule access control (#4389)
The redirect() in constructor returned a RedirectResponse that was never executed, allowing unauthorized access to report submodules. Replaced with header() + exit() to enforce permission checks.
2026-03-04 21:18:42 +01:00
jekkos
3c217bbddd Fix XSS vulnerabilities in invoice_email.php view 2026-03-04 17:54:01 +00:00
jekkos
87a0606141 Fix XSS vulnerability in register (#3965) 2026-03-03 22:40:50 +01:00
jekkos
b6a90f7880 Fix XSS vulnerability in register (#3965) 2026-03-03 22:37:08 +01:00
jekkos
b93359bcaf Fix XSS vulnerability in attributes (#3965) 2026-03-03 22:28:32 +01:00
jekkos
79427481b3 Fix XSS vulnerabilities in invoices + receipts (#3965) (#4363) 2026-02-23 20:14:55 +01:00
dependabot[bot]
b23351a45c Bump jspdf and jspdf-autotable (#4373)
Bumps [jspdf](https://github.com/parallax/jsPDF) and [jspdf-autotable](https://github.com/simonbengtsson/jsPDF-AutoTable). These dependencies needed to be updated together.

Updates `jspdf` from 3.0.2 to 4.1.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/v3.0.2...v4.1.0)

Updates `jspdf-autotable` from 5.0.2 to 5.0.7
- [Release notes](https://github.com/simonbengtsson/jsPDF-AutoTable/releases)
- [Commits](https://github.com/simonbengtsson/jsPDF-AutoTable/compare/v5.0.2...v5.0.7)

---
updated-dependencies:
- dependency-name: jspdf
  dependency-version: 4.1.0
  dependency-type: direct:production
- dependency-name: jspdf-autotable
  dependency-version: 5.0.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-07 11:46:11 +00:00
dependabot[bot]
bee0c8e364 Bump lodash from 4.17.21 to 4.17.23 (#4369)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 20:51:03 +01:00
jekkos
849439c71e Fix multiple XSS vulnerabilities (#3965) (#4356) 2025-12-22 17:21:49 +01:00
Chathura Dilushanka
25680f05db Add equals as permitted URI character (#4329)
This should resolve the 400 error when deleting payments with base64 encoded IDs containing `=`.
2025-12-21 22:41:36 +01:00
jekkos
a11fb099e2 Fix travis build after merge (#4130) 2025-12-21 19:51:21 +01:00
BhojKamal
aee5f31cf5 Add show/hide cost price & profit feature - in reports #4130 (#4350)
* Add show/hide cost price & profit feature

* .env should be ignored.

* js code formatted. .vscode folder ignore for vscode user settings.json

* style is replaced with bootstrap class, formatted and .env.example

* toggle button on table to like in other

* comment corrected.

* class re-factored

* minor refactor

* formatted with 4 space

---------

Co-authored-by: Lotussoft Youngtech <lotussoftyoungtech@gmail.com>
2025-12-21 15:23:39 +05:45
jekkos
643b0ac499 Fix for detailed suppliers report (#4351) 2025-12-17 22:46:59 +01:00
jekkos
3e844f2f89 Escape return_policy in receipt + invoice (#4349)
* Escape return_policy in receipt + invoice

* Enable CSRF using session token (#3632)
2025-12-17 20:39:58 +01:00
jekkos
2acdec431f Fix wrong migration script location (#4285) 2025-12-08 23:06:48 +01:00
jekkos
f245f585da Fix creation of date attribute value (#4310) (#4344)
Fix type hints in case search string is empty in sales
2025-12-02 07:19:14 +01:00
jekkos
e48ab45094 Fix toast notifications in config (#4341) (#4343) 2025-11-28 09:01:07 +01:00
jekkos
46e31b1c16 Allow anonymous giftcard creation (#4278)
* Allow giftcard without person (#4276)

* Update giftcard form validation (#4276)
2025-11-24 22:54:52 +01:00
jekkos
bea69c7aa1 Add DOMPurify to JS includes (#4341) 2025-11-23 22:20:40 +01:00
jekkos
30da69a382 Fix attachment cid (#4314)
* Add attachment cid when sending emails (#4308)

Also check if an encryption key is set before decrypting the SMTP
password.

* Upgrade to CI 4.6.3 (#4308)

* Fix for changing invoice id in email (#4308)
2025-11-23 21:37:32 +01:00
jekkos
6dd5a9162f Add DOMpurify + fix XSS (#4341) 2025-11-23 21:35:47 +01:00
jekkos
26a398f7d2 Add recent releases to issue template (#4317) 2025-11-21 23:55:24 +01:00
jekkos
ce73d9bb31 Add env variable to disallow pwd change (#4325) 2025-11-21 23:46:48 +01:00
jekkos
83af580d40 Add server side validation for password (#4335) 2025-11-21 23:45:47 +01:00
jekkos
ca7adf76c1 Update SECURITY.md contact (#4335) 2025-11-21 23:22:39 +01:00
jekkos
832db664e5 Fix tax configuration pages (#4331) 2025-11-21 22:13:35 +01:00
jekkos
36e73a84af Clean up docker compose setup (#4308) 2025-10-27 21:57:12 +01:00
Joe Williams
bcddf482fe [Feature] Add logging to migrations (#4327)
* `execute_script()` now returns a boolean for error handling.

* Added transaction to `Migration_MissingConfigKeys.up()`.

* Added logging to various migrations.

* Added transaction to `Migration_MissingConfigKeys.up()`.

* Added logging to various migrations.

* Formatting and function call fixes

Fixed a minor formatting issue in the migration helper.
Replaced a few remaining error_log() calls.
Updated executeScriptWithTransaction() to use log_message()

* Function call fix

Replaced the last error_log() calls with log_message().

---------

Co-authored-by: Joe Williams <hey-there-joe@outlook.com>
2025-10-19 22:10:28 -07:00
Joe Williams
759356288b Add transactions to missing config keys migration. (#4318)
* `execute_script()` now returns a boolean for error handling.

* Added transaction to `Migration_MissingConfigKeys.up()`.

* Added `executeScriptWithTransaction()` to migration helpers.

* Many changes for testing; also minor formatting fixes.

* Removed test code and pointed the `NullableTaxCategoryId` migration at the right SQL file.

* Fixed header.php

* Code cleanup from code review:
- Added IGNORE to SQL scripts.
- Added try-catch to executeScriptWithTransaction().
- Various comment changes.

* Fixed naming issue

Nullable tax category ID migration now runs the correct script.

* Updated SQL

Replaced INSERT WHERE NOT EXISTS in missing config keys sql script to use a single INSERT IGNORE.

* Updated migration helper

Updated executeScriptWithTransaction to use transRollback

---------

Co-authored-by: Joe Williams <hey-there-joe@outlook.com>
2025-10-15 22:53:14 -07:00
j2272850861-pixel
d1e5575ac1 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (8 of 8 strings)

Translation: opensourcepos/bootstrap_tables
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/bootstrap_tables/pt_BR/
2025-10-10 12:58:48 +02:00
j2272850861-pixel
b3f67a5e0f Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (85 of 85 strings)

Translation: opensourcepos/common
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/common/pt_BR/
2025-10-10 12:58:48 +02:00
j2272850861-pixel
41b349134a Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (12 of 12 strings)

Translation: opensourcepos/login
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/login/pt_BR/
2025-10-10 12:58:48 +02:00
jekkos
b1f6ae6d35 Fix mount path for uploads (#4308)
Remove duplicated compose sections in nginx version.  We will include
parts of the main file instead of duplicating it here.
2025-08-29 09:12:02 +02:00
dependabot[bot]
4153c69ccd Bump jspdf from 3.0.1 to 3.0.2 (#4309)
Bumps [jspdf](https://github.com/parallax/jsPDF) from 3.0.1 to 3.0.2.
- [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/v3.0.1...v3.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-29 07:32:54 +02:00
jekkos
87fbd72478 Add generic try/catch in import (#4302) 2025-08-28 00:05:58 +02:00
jekkos
a4ac42b4ad Fix reference to uploads folder (#4270) (#4286) 2025-08-18 21:19:36 +02:00
jekkos
2eff79a8b6 Fix for suspended sales (#4283) (#4303) 2025-08-15 23:12:35 +02:00
Aril Apria Susanto
880fb8faef Translated using Weblate (Indonesian)
Currently translated at 100.0% (327 of 327 strings)

Translation: opensourcepos/config
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/config/id/
2025-08-11 10:27:22 +02:00
Aril Apria Susanto
4d2347173b Translated using Weblate (Indonesian)
Currently translated at 100.0% (85 of 85 strings)

Translation: opensourcepos/common
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/common/id/
2025-08-11 10:27:22 +02:00
Aril Apria Susanto
82d36d01fb Translated using Weblate (Indonesian)
Currently translated at 100.0% (45 of 45 strings)

Translation: opensourcepos/module
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/module/id/
2025-08-11 10:27:22 +02:00
Aril Apria Susanto
13314b7da1 Translated using Weblate (Indonesian)
Currently translated at 100.0% (53 of 53 strings)

Translation: opensourcepos/customers
Translate-URL: https://translate.opensourcepos.org/projects/opensourcepos/customers/id/
2025-08-11 10:27:22 +02:00
jekkos
43808c5970 Revert toast message sanitization (#4302) 2025-08-07 23:49:54 +02:00
jekkos
1615ef3832 Set release version to 3.4.2 2025-08-07 21:06:11 +02:00
jekkos
e089dc5e2c Fix item kits update (#4294) 2025-08-06 23:40:00 +02:00
jekkos
4cf70a95e6 Fix security incident email address (#4298) 2025-07-30 08:05:58 +02:00
jekkos
e08367aaae Allow empty tax category id (#4285) (#4288) 2025-07-29 23:59:23 +02:00
jekkos
9cd2f685ff Fix barcode generation in items (#4270) 2025-07-29 23:56:50 +02:00
jekkos
6800f338e7 Upgrade to ci 4.6.2 (#4296) (#4298) 2025-07-29 23:20:24 +02:00
jekkos
d4ab56b742 Fix migration 20250522000000 (#4284)
* Fix migration errors

Add dropColumnIfExists to migration_helper

* Add config key/values if missing (#4282)
2025-07-16 23:28:24 +02:00
jekkos
1eb75d6e05 Fix typo in writeable (#4270) 2025-07-11 23:23:13 +02:00
jekkos
8833420917 Upgrade github workflow (#3708) (#4280)
Co-authored-by: El_Coloso <diegoramosp@gmail.com>
2025-07-11 23:13:44 +02:00
jekkos
0d1f4efe3c Extended payment delete fix (#4274)
* Create a  Base64 URL-Safe encoding and decoding helper

* Rename web_helper to url_helper

---------

Co-authored-by: El_Coloso <diegoramosp@gmail.com>
2025-07-07 13:57:03 +02:00
jekkos
b9e17daac7 Fix writable folder permission check (#4270) (#4273) 2025-07-06 22:04:17 +02:00
679 changed files with 20233 additions and 10747 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

@@ -1,63 +0,0 @@
#--------------------------------------------------------------------
# ENVIRONMENT
#--------------------------------------------------------------------
CI_ENVIRONMENT = production
#--------------------------------------------------------------------
# DATABASE
#--------------------------------------------------------------------
database.default.hostname = 'localhost'
database.default.database = 'ospos'
database.default.username = 'admin'
database.default.password = 'pointofsale'
database.default.DBDriver = 'MySQLi'
database.default.DBPrefix = 'ospos_'
database.development.hostname = 'localhost'
database.development.database = 'ospos'
database.development.username = 'admin'
database.development.password = 'pointofsale'
database.development.DBDriver = 'MySQLi'
database.development.DBPrefix = 'ospos_'
database.tests.hostname = 'localhost'
database.tests.database = 'ospos'
database.tests.username = 'admin'
database.tests.password = 'pointofsale'
database.tests.DBDriver = 'MySQLi'
database.tests.DBPrefix = 'ospos_'
#--------------------------------------------------------------------
# ENCRYPTION
#--------------------------------------------------------------------
encryption.key = ''
#--------------------------------------------------------------------
# LOGGER
# - 0 = Disables logging, Error logging TURNED OFF
# - 1 = Emergency Messages - System is unusable
# - 2 = Alert Messages - Action Must Be Taken Immediately
# - 3 = Critical Messages - Application component unavailable, unexpected exception.
# - 4 = Runtime Errors - Don't need immediate action, but should be monitored.
# - 5 = Warnings - Exceptional occurrences that are not errors.
# - 6 = Notices - Normal but significant events.
# - 7 = Info - Interesting events, like user logging in, etc.
# - 8 = Debug - Detailed debug information.
# - 9 = All Messages
#--------------------------------------------------------------------
logger.threshold = 0
app.db_log_enabled = false
#--------------------------------------------------------------------
# HONEYPOT
#--------------------------------------------------------------------
honeypot.hidden = true
honeypot.label = 'Fill This Field'
honeypot.name = 'honeypot'
honeypot.template = '<label>{label}</label><input type="text" name="{name}" value="">'
honeypot.container = '<div style="display:none">{template}</div>'

View File

@@ -3,13 +3,27 @@
#--------------------------------------------------------------------
CI_ENVIRONMENT = production
CI_DEBUG = false
#--------------------------------------------------------------------
# APP
# SECURITY: ALLOWED HOSTNAMES
#--------------------------------------------------------------------
app.appTimezone = 'UTC'
# CRITICAL: Whitelist of allowed hostnames to prevent Host Header
# Injection attacks (GHSA-jchf-7hr6-h4f3).
#
# REQUIRED IN PRODUCTION: Application will fail to start if not configured.
# In development, falls back to 'localhost' with an error log.
#
# Configure with comma-separated list of domains/subdomains:
# app.allowedHostnames = 'yourdomain.com,www.yourdomain.com'
#
# Or via environment variable (useful for Docker/Compose):
# ALLOWED_HOSTNAMES=yourdomain.com,www.yourdomain.com
#
# For local development:
# app.allowedHostnames = 'localhost'
#
# Note: Do not include protocol (http/https) or port numbers.
app.allowedHostnames = ''
#--------------------------------------------------------------------
# DATABASE
@@ -21,7 +35,6 @@ database.default.username = 'admin'
database.default.password = 'pointofsale'
database.default.DBDriver = 'MySQLi'
database.default.DBPrefix = 'ospos_'
database.default.port = 3306
database.development.hostname = 'localhost'
database.development.database = 'ospos'
@@ -29,7 +42,6 @@ database.development.username = 'admin'
database.development.password = 'pointofsale'
database.development.DBDriver = 'MySQLi'
database.development.DBPrefix = 'ospos_'
database.development.port = 3306
database.tests.hostname = 'localhost'
database.tests.database = 'ospos'
@@ -37,20 +49,6 @@ database.tests.username = 'admin'
database.tests.password = 'pointofsale'
database.tests.DBDriver = 'MySQLi'
database.tests.DBPrefix = 'ospos_'
database.tests.charset = utf8mb4
database.tests.DBCollat = utf8mb4_general_ci
database.tests.port = 3306
#--------------------------------------------------------------------
# EMAIL
#--------------------------------------------------------------------
email.SMTPHost = ''
email.SMTPUser = ''
email.SMTPPass = ''
email.SMTPPort =
email.SMTPTimeout = 5
email.SMTPCrypto = 'tls'
#--------------------------------------------------------------------
# ENCRYPTION
@@ -58,16 +56,6 @@ email.SMTPCrypto = 'tls'
encryption.key = ''
#--------------------------------------------------------------------
# HONEYPOT
#--------------------------------------------------------------------
honeypot.hidden = true
honeypot.label = 'Fill This Field'
honeypot.name = 'honeypot'
honeypot.template = '<label>{label}</label><input type="text" name="{name}" value="">'
honeypot.container = '<div style="display:none">{template}</div>'
#--------------------------------------------------------------------
# LOGGER
# - 0 = Disables logging, Error logging TURNED OFF
@@ -84,4 +72,13 @@ honeypot.container = '<div style="display:none">{template}</div>'
logger.threshold = 0
app.db_log_enabled = false
app.db_log_only_long = false
#--------------------------------------------------------------------
# HONEYPOT
#--------------------------------------------------------------------
honeypot.hidden = true
honeypot.label = 'Fill This Field'
honeypot.name = 'honeypot'
honeypot.template = '<label>{label}</label><input type="text" name="{name}" value="">'
honeypot.container = '<div style="display:none">{template}</div>'

View File

@@ -1,119 +1,187 @@
name: Bug Report
description: File a bug report
title: "[Bug]: "
labels: ["bug", "triage"]
projects: ["ospos/3", "ospos/4"]
assignees:
- none
body:
- type: markdown
attributes:
value: |
Bug reports indicate that something is not working as intended.
Please include as much detail as possible and submit a separate bug report for each problem.
Do not include personal identifying information such as email addresses or encryption keys.
- type: textarea
id: bug-description
attributes:
label: Bug Description?
description: Describe the problem that you are seeing
placeholder: "Describe the problem that you are seeing"
validations:
required: true
- type: textarea
id: steps-reproduce
attributes:
label: Steps to Reproduce?
description: List the steps to reproduce this issue
placeholder: "Steps to Reproduce"
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior?
description: Tell us what did you expect to happen?
placeholder: "Expected Behavior"
validations:
required: true
- type: dropdown
id: ospos-version
attributes:
label: OpensourcePOS Version
description: What version of our software are you running?
options:
- opensourcepos 3.3.9
- opensourcepos 3.3.8
- opensourcepos 3.3.7
- development (unreleased)
default: 0
validations:
required: true
- type: dropdown
id: php-version
attributes:
label: Php version
description: What version of Php?
options:
- Php 7.2
- Php 7.3
- Php 7.4
- Php 8.1
- Php 8.2
- Php 8.3
- Php 8.4
default: 0
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- Other
- type: input
id: server
attributes:
label: Server Operating System and version
description: "Server Operating System "
placeholder: "Server Operating System "
validations:
required: true
- type: input
id: database
attributes:
label: Database Management System and version
description: "Database Management System"
placeholder: "Database Management"
validations:
required: true
- type: input
id: webserver
attributes:
label: Web Server and version
description: "Web Server and version "
placeholder: "Web Server and version "
validations:
required: true
- type: textarea
id: servers
attributes:
label: System Information Report (optional)
description: Copy and paste from OSPOS > Configuration > Setup & Conf > Setup & Conf?
placeholder: System Information Report
value: "System Information Report"
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Unmodified copy of OpensourcePOS
description: By submitting this issue you agree this copy has not been modified
options:
- label: I agree this copy has not been modified
required: true
name: 🐛 Bug Report
description: File a bug report to help us improve
title: "[Bug]: "
labels: ["bug", "triage"]
projects: ["ospos/3", "ospos/4"]
assignees: []
body:
# ─────────────────────────────────────────────────────────────────────────────
# INTRODUCTION
# ─────────────────────────────────────────────────────────────────────────────
- type: markdown
attributes:
value: |
## Thanks for taking the time to fill out this bug report! 🐜
Bug reports help us identify and fix issues. Please provide as much detail as possible.
> ⚠️ **Important:** Submit a separate bug report for each problem you encounter.
>
> 🚫 Do not include personal identifying information such as email addresses or encryption keys.
# ─────────────────────────────────────────────────────────────────────────────
# PROBLEM DESCRIPTION
# ─────────────────────────────────────────────────────────────────────────────
- type: textarea
id: bug-description
attributes:
label: 🐛 Bug Description
description: A clear and concise description of what the bug is.
placeholder: |
Example: When I try to print a receipt, the application crashes
with an error message saying "Unable to connect to printer".
validations:
required: true
- type: textarea
id: steps-reproduce
attributes:
label: 📋 Steps to Reproduce
description: Detailed steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: ✅ Expected Behavior
description: A clear and concise description of what you expected to happen.
placeholder: |
Example: The receipt should print successfully without any errors.
validations:
required: true
# ─────────────────────────────────────────────────────────────────────────────
# ENVIRONMENT DETAILS
# ─────────────────────────────────────────────────────────────────────────────
- type: dropdown
id: ospos-version
attributes:
label: 📦 OpenSourcePOS Version
description: What version of our software are you running?
options:
- development (unreleased)
- OpenSourcePOS 3.4.2
- OpenSourcePOS 3.4.1
- OpenSourcePOS 3.4.0
- OpenSourcePOS 3.3.9
- OpenSourcePOS 3.3.8
default: 0
validations:
required: true
- type: dropdown
id: php-version
attributes:
label: 🔧 PHP Version
description: What version of PHP are you running?
options:
- PHP 8.4
- PHP 8.3
- PHP 8.2
- PHP 8.1
- PHP 7.4
- Other
default: 0
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: 🌐 Browser(s)
description: What browser(s) are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- Other
- type: input
id: server
attributes:
label: 🖥️ Server Operating System
description: What server OS and version are you running?
placeholder: "e.g., Ubuntu 22.04, CentOS 7, Windows Server 2022"
validations:
required: true
- type: input
id: database
attributes:
label: 🗄️ Database
description: What database management system and version are you using?
placeholder: "e.g., MySQL 8.0, MariaDB 10.11, Percona 8.0"
validations:
required: true
- type: input
id: webserver
attributes:
label: 🌍 Web Server
description: What web server and version are you using?
placeholder: "e.g., Apache 2.4, Nginx 1.24, Caddy 2.7"
validations:
required: true
# ─────────────────────────────────────────────────────────────────────────────
# ADDITIONAL INFORMATION
# ─────────────────────────────────────────────────────────────────────────────
- type: textarea
id: system-info
attributes:
label: 📊 System Information Report
description: |
Copy and paste the system information from OSPOS:
**Navigation:** Configuration → Setup & Conf → System Info
placeholder: |
Paste the System Information Report here...
render: text
validations:
required: true
- type: textarea
id: logs
attributes:
label: 📜 Relevant Log Output
description: |
Please copy and paste any relevant log output.
**Log locations:**
- OSPOS logs: `writable/logs/`
- Web server logs: `/var/log/apache2/` or `/var/log/nginx/`
- PHP logs: Check your `php.ini` for `error_log` location
placeholder: |
Paste log output here...
render: shell
- type: textarea
id: screenshots
attributes:
label: 📸 Screenshots
description: If applicable, add screenshots to help explain your problem.
placeholder: Drag and drop images here...
# ─────────────────────────────────────────────────────────────────────────────
# CONFIRMATION
# ─────────────────────────────────────────────────────────────────────────────
- type: checkboxes
id: terms
attributes:
label: ✓ Confirmation
description: Please confirm the following before submitting
options:
- label: I certify that this is an unmodified copy of OpenSourcePOS
required: true
- label: I have searched existing issues to ensure this bug has not already been reported
required: true
- label: I have provided all the information requested above
required: true

View File

@@ -1,63 +1,136 @@
name: ✨ Feature Request
description: Suggest an idea for this project
title: "[Feature]: "
labels: ["enhancement"]
assignees: ["none"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request! 🤗
Please make sure this feature request hasn't been already submitted by someone by looking through other open/closed issues. 😃
- type: dropdown
attributes:
multiple: false
label: Type of Feature
description: Select the type of feature request.
options:
- "✨ New Feature"
- "📝 Documentation"
- "🎨 Style and UI"
- "🔨 Code Refactor"
- "⚡ Performance Improvements"
- "✅ New Test"
validations:
required: true
- type: dropdown
id: ospos-version
attributes:
label: OpensourcePOS Version
description: What version of our software are you running?
options:
- opensourcepos 3.3.9
- opensourcepos 3.3.8
- opensourcepos 3.3.7
default: 0
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Give us a brief description of the feature or enhancement you would like
validations:
required: true
- type: textarea
id: additional-information
attributes:
label: Additional Information
description: Give us some additional information on the feature request like proposed solutions, links, screenshots, etc.
- type: checkboxes
id: terms
attributes:
label: Verify you searched open requests in OpensourcePOS
description: By submitting this request you agree that you have searched Open Requests in the Tracker
options:
- label: I agree I have searched Open Requests
required: true
name: ✨ Feature Request
description: Suggest an idea or enhancement for this project
title: "[Feature]: "
labels: ["enhancement"]
assignees: []
body:
# ─────────────────────────────────────────────────────────────────────────────
# INTRODUCTION
# ─────────────────────────────────────────────────────────────────────────────
- type: markdown
attributes:
value: |
## Thanks for suggesting a new feature! 💡
We appreciate you taking the time to help improve OpenSourcePOS.
> 📋 **Before submitting:** Please search [existing feature requests](https://github.com/opensourcepos/opensourcepos/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement) to ensure your idea hasn't already been suggested.
# ─────────────────────────────────────────────────────────────────────────────
# FEATURE DETAILS
# ─────────────────────────────────────────────────────────────────────────────
- type: dropdown
id: feature-type
attributes:
label: 🏷️ Feature Type
description: What type of feature are you requesting?
options:
- "✨ New Feature"
- "📝 Documentation Improvement"
- "🎨 UI/UX Enhancement"
- "🔨 Code Refactoring"
- "⚡ Performance Improvement"
- "✅ New Test Coverage"
- "🔌 Plugin/Integration"
default: 0
validations:
required: true
- type: dropdown
id: ospos-version
attributes:
label: 📦 OpenSourcePOS Version
description: What version are you currently running?
options:
- development (unreleased)
- OpenSourcePOS 3.4.2
- OpenSourcePOS 3.4.1
- OpenSourcePOS 3.4.0
- OpenSourcePOS 3.3.9
- OpenSourcePOS 3.3.8
default: 0
validations:
required: true
- type: textarea
id: problem-statement
attributes:
label: 🎯 Problem Statement
description: |
Is your feature request related to a problem? Please describe.
A clear description of what the problem is. Ex: I'm always frustrated when [...]
placeholder: |
Example: I always have to manually calculate taxes for different regions,
which is time-consuming and error-prone.
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: 💡 Proposed Solution
description: A clear and concise description of what you want to happen.
placeholder: |
Example: Add an automatic tax calculation feature that:
- Detects the customer's region
- Applies the correct tax rate
- Generates a tax report automatically
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 🔄 Alternatives Considered
description: A clear description of any alternative solutions or features you've considered.
placeholder: |
Example: I considered using an external tax service, but it would be
better to have this integrated directly into OpenSourcePOS.
# ─────────────────────────────────────────────────────────────────────────────
# ADDITIONAL INFORMATION
# ─────────────────────────────────────────────────────────────────────────────
- type: textarea
id: additional-context
attributes:
label: 📎 Additional Context
description: |
Add any other context, screenshots, mockups, or references about the feature request here.
**Helpful additions:**
- Links to similar features in other software
- Mockups or diagrams
- Code examples
- Documentation references
placeholder: |
Any other relevant information, links, or screenshots...
- type: textarea
id: acceptance-criteria
attributes:
label: ✅ Acceptance Criteria
description: |
(Optional) Define what "done" looks like for this feature.
Format: **Given** [context], **When** [action], **Then** [outcome]
placeholder: |
Given a customer is selected from region X
When the sale is completed
Then the tax rate for region X is automatically applied
And the tax amount is correctly calculated
And a tax entry is logged in the report
# ─────────────────────────────────────────────────────────────────────────────
# CONFIRMATION
# ─────────────────────────────────────────────────────────────────────────────
- type: checkboxes
id: terms
attributes:
label: ✓ Confirmation
description: Please confirm before submitting
options:
- label: I have searched existing feature requests to ensure this is not a duplicate
required: true
- label: I have provided a clear problem statement and proposed solution
required: true

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

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

@@ -0,0 +1,215 @@
name: Build and Release
on:
push:
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\///' | tr '/' '_')
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
include-hidden-files: true
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:master" >> $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

219
.github/workflows/deploy-core.yml vendored Normal file
View File

@@ -0,0 +1,219 @@
name: Deploy Core
on:
workflow_call:
inputs:
image_tag:
description: 'Docker image tag to deploy'
type: string
required: true
sha:
description: 'Git commit SHA to deploy'
type: string
required: true
description:
description: 'Deployment description'
type: string
required: true
pr_number:
description: 'Pull request number (optional)'
type: string
required: false
outputs:
deployment_id:
description: 'GitHub deployment ID'
value: ${{ jobs.deploy.outputs.deployment_id }}
status:
description: 'Deployment status (success/failure)'
value: ${{ jobs.deploy.outputs.status }}
concurrency:
group: deploy-staging
cancel-in-progress: false
permissions:
contents: read
deployments: write
jobs:
deploy:
name: Deploy to staging
runs-on: ubuntu-latest
environment:
name: staging
url: ${{ vars.DEPLOY_URL || 'https://dev.opensourcepos.org' }}
deployment: false
outputs:
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
status: ${{ steps.webhook.outputs.status }}
steps:
- name: Create GitHub Deployment
id: deployment
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ inputs.image_tag }}
REF_SHA: ${{ inputs.sha }}
DESCRIPTION: ${{ inputs.description }}
run: |
set -euo pipefail
DEPLOYMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/deployments" \
-X POST \
-f ref="${REF_SHA}" \
-f environment="staging" \
-f description="${DESCRIPTION}" \
-F auto_merge=false \
-F required_contexts[] \
--jq '.id')
if [ -z "$DEPLOYMENT_ID" ]; then
echo "::error::Failed to create deployment"
exit 1
fi
echo "deployment_id=$DEPLOYMENT_ID" >> "$GITHUB_OUTPUT"
echo "Created deployment: $DEPLOYMENT_ID"
- name: Set deployment status to in_progress
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="in_progress" \
-f description="Deployment in progress..." \
-f log_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
- name: Trigger deployment webhook
id: webhook
env:
DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}
DEPLOY_WEBHOOK_SECRET: ${{ secrets.DEPLOY_WEBHOOK_SECRET }}
DOCKER_REPO_NAME: ${{ secrets.DOCKER_REPO_NAME }}
IMAGE_TAG: ${{ inputs.image_tag }}
REF_SHA: ${{ inputs.sha }}
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
if [ -z "$DEPLOY_WEBHOOK_URL" ]; then
echo "::error::DEPLOY_WEBHOOK_URL secret is not configured"
echo "Please add the DEPLOY_WEBHOOK_URL secret in your repository settings"
echo "status=failure" >> "$GITHUB_OUTPUT"
exit 1
fi
REPO_NAME="${DOCKER_REPO_NAME:-opensourcepos/opensourcepos}"
REPO_NAMESPACE="${REPO_NAME%%/*}"
REPO_SHORT_NAME="${REPO_NAME#*/}"
PUSHED_AT=$(date +%s)
if [ -n "$PR_NUMBER" ]; then
PAYLOAD=$(jq -n \
--arg callback_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--argjson pushed_at "$PUSHED_AT" \
--arg pusher "$GITHUB_ACTOR" \
--arg tag "$IMAGE_TAG" \
--arg repo_name "$REPO_NAME" \
--arg name "$REPO_SHORT_NAME" \
--arg namespace "$REPO_NAMESPACE" \
--arg repo_url "https://hub.docker.com/r/${REPO_NAME}/" \
--arg deployment_id "$DEPLOYMENT_ID" \
--arg repository "$GITHUB_REPOSITORY" \
--arg sha "$REF_SHA" \
--arg run_id "$GITHUB_RUN_ID" \
--arg actor "$GITHUB_ACTOR" \
--argjson pr_number "$PR_NUMBER" \
'{
callback_url: $callback_url,
push_data: {pushed_at: $pushed_at, pusher: $pusher, tag: $tag},
repository: {repo_name: $repo_name, name: $name, namespace: $namespace, repo_url: $repo_url, status: "Active"},
github_deployment: {id: $deployment_id, environment: "staging", repository: $repository, sha: $sha, run_id: $run_id, actor: $actor, pull_request: $pr_number}
}')
else
PAYLOAD=$(jq -n \
--arg callback_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--argjson pushed_at "$PUSHED_AT" \
--arg pusher "$GITHUB_ACTOR" \
--arg tag "$IMAGE_TAG" \
--arg repo_name "$REPO_NAME" \
--arg name "$REPO_SHORT_NAME" \
--arg namespace "$REPO_NAMESPACE" \
--arg repo_url "https://hub.docker.com/r/${REPO_NAME}/" \
--arg deployment_id "$DEPLOYMENT_ID" \
--arg repository "$GITHUB_REPOSITORY" \
--arg sha "$REF_SHA" \
--arg run_id "$GITHUB_RUN_ID" \
--arg actor "$GITHUB_ACTOR" \
'{
callback_url: $callback_url,
push_data: {pushed_at: $pushed_at, pusher: $pusher, tag: $tag},
repository: {repo_name: $repo_name, name: $name, namespace: $namespace, repo_url: $repo_url, status: "Active"},
github_deployment: {id: $deployment_id, environment: "staging", repository: $repository, sha: $sha, run_id: $run_id, actor: $actor}
}')
fi
echo "Sending webhook..."
echo "Image: ${IMAGE_TAG}"
echo "Environment: staging"
HEADERS=(-H "Content-Type: application/json")
if [ -n "$DEPLOY_WEBHOOK_SECRET" ]; then
SIGNATURE=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$DEPLOY_WEBHOOK_SECRET" | sed 's/.*= //')
HEADERS+=(-H "X-Hub-Signature-256: sha256=$SIGNATURE")
echo "Using HMAC-SHA256 signature verification"
else
echo "::warning::DEPLOY_WEBHOOK_SECRET not set - webhook calls will not be signed"
echo "For security, configure DEPLOY_WEBHOOK_SECRET in your repository settings"
fi
HTTP_CODE=$(curl -sS --connect-timeout 10 --max-time 120 \
-o response.txt -w "%{http_code}" \
-X POST \
"${HEADERS[@]}" \
-d "$PAYLOAD" \
"$DEPLOY_WEBHOOK_URL") || HTTP_CODE="000"
echo "Response code: $HTTP_CODE"
if [ -s response.txt ]; then
cat response.txt
fi
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "status=success" >> "$GITHUB_OUTPUT"
else
echo "status=failure" >> "$GITHUB_OUTPUT"
fi
- name: Set deployment status
if: always()
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
STATE="${{ steps.webhook.outputs.status }}"
if [ "$STATE" = "success" ]; then
DESCRIPTION=$(jq -nr --arg tag "$IMAGE_TAG" \
'"Deployed image \($tag) to staging"')
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="success" \
-f description="$DESCRIPTION"
else
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="failure" \
-f description="Deployment failed"
exit 1
fi

79
.github/workflows/deploy-pr.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: PR Deploy
on:
pull_request_review:
types: [submitted]
concurrency:
group: staging-deploy
cancel-in-progress: false
permissions:
contents: read
deployments: write
pull-requests: write
jobs:
prepare:
name: Prepare deployment
runs-on: ubuntu-latest
if: >
github.event.review.state == 'approved' &&
github.event.pull_request.head.repo.full_name == github.repository
outputs:
image_tag: ${{ steps.image.outputs.tag }}
sha: ${{ github.event.pull_request.head.sha }}
pr_number: ${{ github.event.pull_request.number }}
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Get image tag
id: image
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
run: |
IMAGE_TAG="pr-${PR_NUMBER}-${PR_SHA:0:7}"
echo "tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
deploy:
name: Deploy to staging
needs: prepare
uses: ./.github/workflows/deploy-core.yml
with:
image_tag: ${{ needs.prepare.outputs.image_tag }}
sha: ${{ needs.prepare.outputs.sha }}
description: Deploy PR #${{ needs.prepare.outputs.pr_number }} to staging
pr_number: ${{ needs.prepare.outputs.pr_number }}
secrets: inherit
comment:
name: Comment deployment status
needs: [prepare, deploy]
if: always()
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PR_NUMBER: ${{ needs.prepare.outputs.pr_number }}
REF_SHA: ${{ needs.prepare.outputs.sha }}
STATUS: ${{ needs.deploy.outputs.status }}
steps:
- name: Comment on PR
run: |
if [ "$STATUS" = "success" ]; then
BODY=$(jq -nr --arg tag "$IMAGE_TAG" --arg sha "$REF_SHA" --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
'"✅ **Staging deployment completed**\n\n🔗 **URL**: https://dev.opensourcepos.org\n📦 **Image Tag**: `\($tag)`\n🔨 **Commit**: \($sha)\n\nView logs: \($url)"')
else
BODY=$(jq -nr --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
'"❌ **Staging deployment failed**\n\nCheck the [workflow logs](\($url)) for details."')
fi
gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
-X POST \
-f body="$BODY"

23
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Deploy
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Docker image tag to deploy (e.g., v3.4.0, latest)'
required: true
default: 'latest'
permissions:
contents: read
deployments: write
jobs:
deploy:
name: Deploy to staging
uses: ./.github/workflows/deploy-core.yml
with:
image_tag: ${{ inputs.image_tag }}
sha: ${{ github.sha }}
description: Deploy image ${{ inputs.image_tag }}
secrets: inherit

View File

@@ -28,8 +28,9 @@ jobs:
fail-fast: false
matrix:
php-version:
- '7.4'
- '8.0'
- '8.2'
- '8.3'
- '8.4'
steps:
- name: Checkout

View File

@@ -12,14 +12,6 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: PHP Lint 8.0
uses: dbfx/github-phplint/8.0@master
with:
folder-to-exclude: "! -path \"./vendor/*\" ! -path \"./folder/excluded/*\""
- name: PHP Lint 8.1
uses: dbfx/github-phplint/8.1@master
with:
folder-to-exclude: "! -path \"./vendor/*\" ! -path \"./folder/excluded/*\""
- name: PHP Lint 8.2
uses: dbfx/github-phplint/8.2@master
with:

121
.github/workflows/phpunit.yml vendored Normal file
View File

@@ -0,0 +1,121 @@
name: PHPUnit Tests
on:
push:
paths:
- '**.php'
- 'spark'
- 'tests/**'
- '.github/workflows/phpunit.yml'
- 'gulpfile.js'
- 'app/Database/**'
pull_request:
paths:
- '**.php'
- 'spark'
- 'tests/**'
- '.github/workflows/phpunit.yml'
- 'gulpfile.js'
- 'app/Database/**'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: PHP ${{ matrix.php-version }} Tests
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
php-version:
- '8.2'
- '8.3'
- '8.4'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: intl, mbstring, mysqli
coverage: none
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Get npm cache directory
run: echo "NPM_CACHE_DIR=$(npm config get cache)" >> $GITHUB_ENV
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ${{ env.NPM_CACHE_DIR }}
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install npm dependencies
run: npm install
- name: Start MariaDB
run: |
docker run -d --name mysql \
-e MYSQL_ROOT_PASSWORD=root \
-e MYSQL_DATABASE=ospos \
-e MYSQL_USER=admin \
-e MYSQL_PASSWORD=pointofsale \
-p 3306:3306 \
mariadb:10.5
# Wait for MariaDB to be ready
until docker exec mysql mysqladmin ping -h 127.0.0.1 -u root -proot --silent; do
echo "Waiting for MariaDB..."
sleep 2
done
echo "MariaDB is ready!"
- name: Get composer cache directory
run: echo "COMPOSER_CACHE_FILES_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ${{ env.COMPOSER_CACHE_FILES_DIR }}
key: ${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-${{ matrix.php-version }}-
${{ runner.os }}-
- name: Install dependencies
run: composer update --ansi --no-interaction
- name: Create .env file
run: cp .env.example .env
- name: Run PHPUnit tests
env:
CI_ENVIRONMENT: testing
MYSQL_HOST_NAME: 127.0.0.1
run: composer test -- --log-junit test-results/junit.xml
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-php-${{ matrix.php-version }}
path: test-results/
retention-days: 30
- name: Stop MariaDB
if: always()
run: docker stop mysql && docker rm mysql

172
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,172 @@
name: Release Version Bump
on:
workflow_dispatch:
inputs:
version_type:
description: 'Version bump type'
required: true
type: choice
options:
- minor
- major
- patch
default: 'minor'
permissions:
contents: write
jobs:
prepare-release:
name: Prepare Release
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get current version
id: current_version
run: |
CURRENT_VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g")
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "Current version: $CURRENT_VERSION"
- name: Calculate new version
id: version
run: |
CURRENT_VERSION="${{ steps.current_version.outputs.current_version }}"
VERSION_TYPE="${{ github.event.inputs.version_type }}"
# Parse current version
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
# Bump version based on type
case $VERSION_TYPE in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch)
PATCH=$((PATCH + 1))
;;
esac
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "previous_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "New version: $NEW_VERSION (was: $CURRENT_VERSION, type: $VERSION_TYPE)"
- name: Update version in App.php
run: |
NEW_VERSION="${{ steps.version.outputs.new_version }}"
sed -i "s/public string \\\$application_version = '[^']*';/public string \\\$application_version = '$NEW_VERSION';/" app/Config/App.php
echo "Updated app/Config/App.php"
- name: Update version in package.json
run: |
NEW_VERSION="${{ steps.version.outputs.new_version }}"
sed -i "s/\"version\": \"[^\"]*\",/\"version\": \"$NEW_VERSION\",/" package.json
echo "Updated package.json"
- name: Update version in docker-compose.nginx.yml
run: |
NEW_VERSION="${{ steps.version.outputs.new_version }}"
sed -i "s/jekkos\/opensourcepos:[^ ]*/jekkos\/opensourcepos:$NEW_VERSION/" docker-compose.nginx.yml
echo "Updated docker-compose.nginx.yml"
- name: Update version in README.md
run: |
NEW_VERSION="${{ steps.version.outputs.new_version }}"
# Extract major.minor for the "latest X.Y version" text
MAJOR_MINOR=$(echo "$NEW_VERSION" | cut -d. -f1,2)
sed -i "s/The latest \`[0-9]*\.[0-9]*\` version/The latest \`${MAJOR_MINOR}\` version/" README.md
echo "Updated README.md with version ${MAJOR_MINOR}"
- name: Generate changelog
id: changelog
run: |
PREVIOUS_VERSION="${{ steps.version.outputs.previous_version }}"
NEW_VERSION="${{ steps.version.outputs.new_version }}"
# Get commits since last version
if git rev-parse "$PREVIOUS_VERSION" >/dev/null 2>&1; then
COMMITS=$(git log "$PREVIOUS_VERSION"..HEAD --pretty=format:"- %s" --no-merges)
else
COMMITS=$(git log --pretty=format:"- %s" --no-merges -50)
fi
# Create changelog entry
CHANGELOG_FILE="CHANGELOG.md"
# Create the new version comparison link
NEW_LINK="[${NEW_VERSION}]: https://github.com/opensourcepos/opensourcepos/compare/${PREVIOUS_VERSION}...${NEW_VERSION}"
# Insert new link after [unreleased] line
sed -i "/^\[unreleased\]/a $NEW_LINK" "$CHANGELOG_FILE"
# Update [unreleased] link to start from new version
sed -i "s|^\[unreleased\]: .*|\[unreleased\]: https://github.com/opensourcepos/opensourcepos/compare/${NEW_VERSION}...HEAD|" "$CHANGELOG_FILE"
# Create version header and content using temp file to avoid sed issues with special characters
VERSION_DATE=$(date +%Y-%m-%d)
VERSION_HEADER="## [$NEW_VERSION] - $VERSION_DATE"
# Create temp file with changelog entry
TMP_FILE=$(mktemp)
{
echo ""
echo "$VERSION_HEADER"
echo ""
echo "$COMMITS"
} > "$TMP_FILE"
# Insert after Unreleased header
sed -i "/^## \[Unreleased\]/r $TMP_FILE" "$CHANGELOG_FILE"
rm "$TMP_FILE"
echo "Updated CHANGELOG.md"
echo "Changelog entries:"
echo "$COMMITS"
- name: Update version in issue templates
run: |
NEW_VERSION="${{ steps.version.outputs.new_version }}"
# Calculate version to remove (keep 5 versions)
PREVIOUS_VERSION="${{ steps.version.outputs.previous_version }}"
# Bug report template - insert new version after development (unreleased)
BUG_TEMPLATE=".github/ISSUE_TEMPLATE/bug report.yml"
sed -i "/- development (unreleased)/a\\ - OpenSourcePOS ${NEW_VERSION}" "$BUG_TEMPLATE"
# Remove the oldest version (5th version from the end)
sed -i "/OpenSourcePOS 3\\.3\\.7/d" "$BUG_TEMPLATE"
echo "Updated $BUG_TEMPLATE"
# Feature request template - insert new version after development (unreleased)
FEATURE_TEMPLATE=".github/ISSUE_TEMPLATE/feature_request.yml"
sed -i "/- development (unreleased)/a\\ - OpenSourcePOS ${NEW_VERSION}" "$FEATURE_TEMPLATE"
# Remove the oldest version (5th version from the end)
sed -i "/OpenSourcePOS 3\\.3\\.7/d" "$FEATURE_TEMPLATE"
echo "Updated $FEATURE_TEMPLATE"
- name: Commit version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
NEW_VERSION="${{ steps.version.outputs.new_version }}"
git add app/Config/App.php package.json docker-compose.nginx.yml CHANGELOG.md README.md .github/ISSUE_TEMPLATE/
git commit -m "chore: release version $NEW_VERSION"
git push origin HEAD

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ public/license/*
!public/license/.gitkeep
app/Config/email.php
npm-debug.log*
.vscode
# Docker
!docker/.env

View File

@@ -1,52 +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")
- 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 app/Database/ -t "jekkos/opensourcepos:sql-$TAG"
env:
global:
- BRANCH=$(echo ${TRAVIS_BRANCH} | sed s/feature\\///)
- TAG=${TRAVIS_TAG:-$BRANCH}
- 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,4 +1,4 @@
[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.0...HEAD
[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...HEAD
[3.4.1]: https://github.com/opensourcepos/opensourcepos/compare/3.4.0...3.4.1
[3.4.0]: https://github.com/opensourcepos/opensourcepos/compare/3.3.9...3.4.0
[3.3.9]: https://github.com/opensourcepos/opensourcepos/compare/3.3.8...3.3.9
@@ -33,10 +33,36 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [3.4.0] - 2025-02-06
## [3.4.1] - 2025-06-05
- Feature: PSR-12 Compliant Indentation by @objecttothis in ([#4196](https://github.com/opensourcepos/opensourcepos/pull/4196))
- Add .env to dist zip by @jekkos in ([#4199](https://github.com/opensourcepos/opensourcepos/pull/4199))
- Add CI4 coding standards linter ([#3708](https://github.com/opensourcepos/opensourcepos/issues/3708)) by @jekkos in ([#4198](https://github.com/opensourcepos/opensourcepos/pull/4198))
- Bump canvg from 3.0.10 to 3.0.11 by @dependabot in ([#4189](https://github.com/opensourcepos/opensourcepos/pull/4189))
- Bump jspdf and jspdf-autotable by @dependabot in ([#4190](https://github.com/opensourcepos/opensourcepos/pull/4190))
- Feature bump ci to 4.6.0 by @objecttothis in ([#4197](https://github.com/opensourcepos/opensourcepos/pull/4197))
- Add Kurdish language option to UI by @BudsieBuds in ([#4210](https://github.com/opensourcepos/opensourcepos/pull/4210))
- Convert language ku to ckb by @BudsieBuds in ([#4211](https://github.com/opensourcepos/opensourcepos/pull/4211))
- Fix PHP 8.4 errors by @BudsieBuds in ([#4215](https://github.com/opensourcepos/opensourcepos/pull/4215))
- Add default bootstrap to themes by @BudsieBuds in ([#4219](https://github.com/opensourcepos/opensourcepos/pull/4219))
- Update language names by @BudsieBuds in ([#4218](https://github.com/opensourcepos/opensourcepos/pull/4218))
- Update install docs by @BudsieBuds in ([#4217](https://github.com/opensourcepos/opensourcepos/pull/4217))
- Convert menu icons to SVG by @BudsieBuds in ([#4220](https://github.com/opensourcepos/opensourcepos/pull/4220))
- Enhance license handling by @BudsieBuds in ([#4223](https://github.com/opensourcepos/opensourcepos/pull/4223))
- Fix datetime rendering ([#4226](https://github.com/opensourcepos/opensourcepos/issues/4226)) by @jekkos in ([#4227](https://github.com/opensourcepos/opensourcepos/pull/4227))
- Fix datetime rendering by @jekkos in ([#4228](https://github.com/opensourcepos/opensourcepos/pull/4228))
- Fix null error when sending by email a receipt of a sale that has no invoice by @diego-ramos in ([#4229](https://github.com/opensourcepos/opensourcepos/pull/4229))
- Update Receivings.php to save form. by @odiea in ([#4231](https://github.com/opensourcepos/opensourcepos/pull/4231))
- Update Cashups.php for ajax cashup total to work. by @odiea in ([#4238](https://github.com/opensourcepos/opensourcepos/pull/4238))
- Coding style updates for PSR-12 compliance & improved readability by @BudsieBuds in ([#4204](https://github.com/opensourcepos/opensourcepos/pull/4204))
- Fix Codeigniter disallowed characters error with payment types that have accents by @diego-ramos in ([#4232](https://github.com/opensourcepos/opensourcepos/pull/4232))
- Fixed broken escape string for success & warning messages by @Franchovy in ([#4253](https://github.com/opensourcepos/opensourcepos/pull/4253))
- Bugfix constraint migration fix by @objecttothis in ([#4230](https://github.com/opensourcepos/opensourcepos/pull/4230))
- Fix item number lookup in sales/receivings ([#4212](https://github.com/opensourcepos/opensourcepos/issues/4212)) by @jekkos in ([#4250](https://github.com/opensourcepos/opensourcepos/pull/4250))
## [3.4.0] - 2025-03-23
- Translation updates (Spanish, Indonesian, Swedish, Urdu, Chinese, Thai, French, Dutch)
- PHP 8.x support
- PHP `8.x` support
- Security fixes (XSS, SQLi)
- Migration to Gulp as buildsystem
- Decimal validation fix

View File

@@ -1,98 +1,85 @@
Contributor Covenant Code of Conduct
Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
[comment]: # (Contributor Covenant 2.1 - from https://www.contributor-covenant.org/version/2/1/code_of_conduct/code_of_conduct.md)
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[INSERT CONTACT METHOD].
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
1. Correction
Community Impact: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
Consequence: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
2. Warning
Community Impact: A violation through a single incident or series of
actions.
Consequence: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
3. Temporary Ban
Community Impact: A serious violation of community standards, including
sustained inappropriate behavior.
Consequence: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
4. Permanent Ban
Community Impact: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
Consequence: A permanent ban from any sort of public interaction within the
community.
Attribution
This Code of Conduct is adapted from the Contributor Covenant,
version 2.1, available at
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
Community Impact Guidelines were inspired by
Mozillas code of conduct enforcement ladder.
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

View File

@@ -1,28 +1,23 @@
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"]
COPY --chown=www-data:www-data . /app
RUN chmod 750 /app/writable/logs /app/writable/uploads /app/writable/cache /app/public/uploads /app/public/uploads/item_pics \
&& chmod 640 /app/writable/uploads/importCustomers.csv \
&& ln -s /app/*[^public] /var/www \
&& rm -rf /var/www/html \
&& ln -nsf /app/public /var/www/html
FROM ospos AS ospos_dev

View File

@@ -1,27 +1,68 @@
## Server Requirements
- PHP version `8.1` to `8.4` are supported, PHP version `≤7.4` is NOT supported. Please note that PHP needs to have the extensions `php-json`, `php-gd`, `php-bcmath`, `php-intl`, `php-openssl`, `php-mbstring`, `php-curl` and `php-xml` installed and enabled. An unstable master build can be downloaded in the releases section.
- PHP version `8.2` to `8.4` are supported, PHP version `≤ 8.1` is NOT supported. Please note that PHP needs to have the extensions `php-json`, `php-gd`, `php-bcmath`, `php-intl`, `php-openssl`, `php-mbstring`, `php-curl` and `php-xml` installed and enabled. An unstable master build can be downloaded in the releases section.
- MySQL `5.7` is supported, also MariaDB replacement `10.x` is supported and might offer better performance.
- Apache `2.4` is supported. Nginx should work fine too, see [wiki page here](https://github.com/opensourcepos/opensourcepos/wiki/Local-Deployment-using-LEMP).
- 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)
⚠️ **CRITICAL**: OpenSourcePOS validates the Host header to prevent Host Header Injection attacks (GHSA-jchf-7hr6-h4f3). **You MUST configure `app.allowedHostnames` for production deployments. If not configured, the application will fail to start.**
**Add to your `.env` file:**
```bash
# Comma-separated list of allowed hostnames (no protocols or ports)
app.allowedHostnames = 'yourdomain.com,www.yourdomain.com'
```
**For local development:**
```bash
app.allowedHostnames = 'localhost'
```
**If you see this error at startup:**
```text
RuntimeException: Security: allowedHostnames is not configured.
```
**Solution**: Add `app.allowedHostnames` to your `.env` file with your domain(s).
**Why this matters:**
- Prevents Host Header Injection attacks (GHSA-jchf-7hr6-h4f3)
- Ensures URLs are generated with the correct domain
- Security advisory: https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-jchf-7hr6-h4f3
- Fixes issue #4480: .env configuration now works via comma-separated values
### 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.
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>
@@ -102,11 +102,11 @@ NOTE: If you're running non-release code, please make sure you always run the la
- If you have suhosin installed and face an issue with CSRF, please make sure you read [issue #1492](https://github.com/opensourcepos/opensourcepos/issues/1492).
- PHP `≥ 8.1` is required to run this app.
- PHP `≥ 8.2` is required to run this app.
## 🏃 Keep the Machine Running
If you like our project, please consider buying us a coffee through the button below so we can keep adding features.
If you like our project, please consider buying us a coffee through the button below so we can keep adding features. Please star the project if you like it!
[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=MUN6AEG7NY6H8)\
Or refer to the [FUNDING.yml](.github/FUNDING.yml) file.
@@ -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,24 +1,137 @@
<!-- 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)
- [Disclosure Process](#disclosure-process)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
<!-- END doctoc generated TOC please keep comment here to allow update -->
# Security Policy
## 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 |
| Version | Supported |
| --------- | ------------------ |
| >= 3.4.2 | :white_check_mark: |
| < 3.4.2 | :x: |
## Security Advisories
For a complete list of published and draft security advisories with CVE details, see our [GitHub Security Advisories page](https://github.com/opensourcepos/opensourcepos/security/advisories).
## Reporting a Vulnerability
Please report (suspected) security vulnerabilities to **[jekkos@opensourcepos.org](mailto:jekkos@opensourcepos.org)**. 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.
**Option 1: GitHub Security Advisory (Preferred)**
1. Create a draft security advisory directly on GitHub:
- Go to https://github.com/opensourcepos/opensourcepos/security/advisories
- Click "New draft security advisory"
- Fill in the vulnerability details using our [template below](#vulnerability-template)
- Submit as **draft** (not published)
2. Notify us for triage:
- Send an email to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)** with:
- Subject: `[GHSA] Brief description of vulnerability`
- Link to the draft advisory
- Brief summary
**Option 2: Email Report**
Send vulnerability details to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**.
You will receive a response within 48 hours. Confirmed vulnerabilities will be patched within a few days depending on complexity.
## Disclosure Process
### Timeline
| Step | Timeline | Action |
|------|----------|--------|
| 1. Report received | Day 0 | We acknowledge within 48 hours |
| 2. Triage & confirmation | Day 1-3 | We validate the vulnerability |
| 3. Fix development | Day 3-7 | We develop and test the fix |
| 4. Patch release | Day 7-10 | We release a security patch |
| 5. CVE request | Day 7-14 | We request CVE from GitHub (if applicable) |
| 6. Advisory published | Day 14 | We publish the advisory with credit |
| 7. Public disclosure | Day 14+ | Full disclosure after patch release |
### CVE Process
**We request CVE identifiers through GitHub's security advisory system.** This is the preferred and easiest method:
1. After we confirm and fix the vulnerability, we'll request a CVE through GitHub
2. GitHub coordinates with MITRE on our behalf
3. The CVE is automatically linked to the advisory
4. You'll be credited as the reporter in the published advisory
**Already have a CVE?** If you've already obtained a CVE from another source (e.g., VulDB, CVE.MITRE.ORG), please include it in your report or advisory. We'll update our advisory to reference the existing CVE.
### No Bug Bounty Program
**Important:** Open Source Point of Sale does not offer a bug bounty program.
- All security research and vulnerability triage is done on a **voluntary basis** in our free time
- We do not offer monetary rewards for vulnerability reports
- We do credit reporters in published advisories (unless anonymity is requested)
- We greatly appreciate the security research community's efforts to help improve project security
### Security Best Practices for Researchers
- **Do not** access, modify, or delete data that doesn't belong to you
- **Do not** perform denial of service attacks
- **Do not** publicly disclose vulnerabilities before we've had time to fix them
- **Do** provide sufficient information to reproduce the vulnerability
- **Do** allow us reasonable time to fix before public disclosure
- **Do** report through official channels (GitHub advisories or email)
### Vulnerability Template
When creating a draft advisory, please include:
```
## Summary
[Brief description of the vulnerability]
## Impact
- **Confidentiality:** [High/Medium/Low - what data can be exposed]
- **Integrity:** [High/Medium/Low - what can be modified]
- **Availability:** [High/Medium/Low - service disruption potential]
- **Privilege Required:** [None/Low/High - authentication level needed]
- **CVSS v3.1:** [Score] ([Vector string])
## Details
[Technical details about the vulnerability]
**Affected Code:**
```php
// Path to affected file and vulnerable code
```
**Attack Vector:**
[How an attacker can exploit this]
## Proof of Concept
```bash
# Steps to reproduce
```
## Patch
[Suggested fix or approach]
## Affected Versions
- OpenSourcePOS X.Y.Z and earlier
## Credit
[Your GitHub username or preferred name]
```
---
**Thank you to all security researchers who have contributed to making Open Source Point of Sale more secure.** Your voluntary efforts help protect thousands of users worldwide and contribute to a safer, more trustworthy free and open-source software ecosystem. We deeply appreciate your responsible disclosure and the time you invest in improving our project.
If you've reported a vulnerability and would like to discuss CVE coordination or have questions about the process, please reach out to us at [jeroen@steganos.dev](mailto:jeroen@steganos.dev).

View File

@@ -12,7 +12,7 @@ class App extends BaseConfig
*
* @var string
*/
public string $application_version = '3.4.1';
public string $application_version = '3.4.2';
/**
* This is the commit hash for the version you are currently using.
@@ -58,9 +58,9 @@ class App extends BaseConfig
* 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/':
* Or via environment variable (useful for Docker/Compose):
* ALLOWED_HOSTNAMES=example.com,www.example.com
*
* ['media.example.com', 'accounts.example.com']
*
* @var list<string>
@@ -278,14 +278,83 @@ class App extends BaseConfig
* @see http://www.html5rocks.com/en/tutorials/security/content-security-policy/
* @see http://www.w3.org/TR/CSP/
*/
public bool $CSPEnabled = false; // TODO: Currently CSP3 tags are not supported so enabling this causes problems with script-src-elem, style-src-attr and style-src-elem
public bool $CSPEnabled = false;
public function __construct()
{
parent::__construct();
// Solution for CodeIgniter 4 limitation: arrays cannot be set from .env
// See: https://github.com/codeigniter4/CodeIgniter4/issues/7311
// Support both: app.allowedHostnames (from .env) and ALLOWED_HOSTNAMES (from environment/Docker)
$envAllowedHostnames = getenv('ALLOWED_HOSTNAMES');
if ($envAllowedHostnames === false || trim($envAllowedHostnames) === '') {
$envAllowedHostnames = getenv('app.allowedHostnames');
}
if ($envAllowedHostnames !== false && trim($envAllowedHostnames) !== '') {
$this->allowedHostnames = array_values(array_filter(
array_map('trim', explode(',', $envAllowedHostnames)),
static fn (string $hostname): bool => $hostname !== ''
));
}
$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.
*
* In production: Fails fast if allowedHostnames is not configured.
* In development: Allows localhost fallback with an error log.
*
* @return string A validated hostname
* @throws \RuntimeException If allowedHostnames is not configured in production
*/
private function getValidHost(): string
{
$httpHost = $_SERVER['HTTP_HOST'] ?? 'localhost';
// Determine environment
// CodeIgniter's test bootstrap sets $_SERVER['CI_ENVIRONMENT'] = 'testing'
// Check $_SERVER first, then $_ENV, then fall back to 'production'
$environment = $_SERVER['CI_ENVIRONMENT'] ?? $_ENV['CI_ENVIRONMENT'] ?? getenv('CI_ENVIRONMENT') ?: 'production';
if (empty($this->allowedHostnames)) {
$errorMessage =
'Security: allowedHostnames is not configured. ' .
'Host header injection protection is disabled. ' .
'Set app.allowedHostnames in your .env file or ALLOWED_HOSTNAMES environment variable. ' .
'Example: app.allowedHostnames = "example.com,www.example.com" ' .
'Received Host: ' . $httpHost;
// Production: Fail explicitly to prevent silent security vulnerabilities
// Testing and development: Allow localhost fallback
if ($environment === 'production') {
throw new \RuntimeException($errorMessage);
}
log_message('error', $errorMessage . ' Using localhost fallback (development only).');
return 'localhost';
}
if (in_array($httpHost, $this->allowedHostnames, true)) {
return $httpHost;
}
// Host not in whitelist - use first configured hostname as fallback
log_message('warning',
'Security: Rejected HTTP_HOST "' . $httpHost . '" - not in allowedHostnames whitelist. ' .
'Using fallback: ' . $this->allowedHostnames[0]
);
return $this->allowedHostnames[0];
}
}

View File

@@ -17,8 +17,6 @@ use CodeIgniter\Config\AutoloadConfig;
*
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*
* @immutable
*/
class Autoload extends AutoloadConfig
{

View File

@@ -6,6 +6,22 @@ use CodeIgniter\Config\BaseConfig;
class CURLRequest extends BaseConfig
{
/**
* --------------------------------------------------------------------------
* CURLRequest Share Connection Options
* --------------------------------------------------------------------------
*
* Share connection options between requests.
*
* @var list<int>
*
* @see https://www.php.net/manual/en/curl.constants.php#constant.curl-lock-data-connect
*/
public array $shareConnectionOptions = [
CURL_LOCK_DATA_CONNECT,
CURL_LOCK_DATA_DNS,
];
/**
* --------------------------------------------------------------------------
* CURLRequest Share Options

View File

@@ -3,6 +3,7 @@
namespace Config;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Cache\Handlers\ApcuHandler;
use CodeIgniter\Cache\Handlers\DummyHandler;
use CodeIgniter\Cache\Handlers\FileHandler;
use CodeIgniter\Cache\Handlers\MemcachedHandler;
@@ -78,7 +79,7 @@ class Cache extends BaseConfig
* Your file storage preferences can be specified below, if you are using
* the File driver.
*
* @var array<string, int|string|null>
* @var array{storePath?: string, mode?: int}
*/
public array $file = [
'storePath' => WRITEPATH . 'cache/',
@@ -95,7 +96,7 @@ class Cache extends BaseConfig
*
* @see https://codeigniter.com/user_guide/libraries/caching.html#memcached
*
* @var array<string, bool|int|string>
* @var array{host?: string, port?: int, weight?: int, raw?: bool}
*/
public array $memcached = [
'host' => '127.0.0.1',
@@ -108,17 +109,28 @@ class Cache extends BaseConfig
* -------------------------------------------------------------------------
* Redis settings
* -------------------------------------------------------------------------
*
* Your Redis server can be specified below, if you are using
* the Redis or Predis drivers.
*
* @var array<string, int|string|null>
* @var array{
* host?: string,
* password?: string|null,
* port?: int,
* timeout?: int,
* async?: bool,
* persistent?: bool,
* database?: int
* }
*/
public array $redis = [
'host' => '127.0.0.1',
'password' => null,
'port' => 6379,
'timeout' => 0,
'database' => 0,
'host' => '127.0.0.1',
'password' => null,
'port' => 6379,
'timeout' => 0,
'async' => false, // specific to Predis and ignored by the native Redis extension
'persistent' => false,
'database' => 0,
];
/**
@@ -132,6 +144,7 @@ class Cache extends BaseConfig
* @var array<string, class-string<CacheInterface>>
*/
public array $validHandlers = [
'apcu' => ApcuHandler::class,
'dummy' => DummyHandler::class,
'file' => FileHandler::class,
'memcached' => MemcachedHandler::class,
@@ -158,4 +171,28 @@ class Cache extends BaseConfig
* @var bool|list<string>
*/
public $cacheQueryString = false;
/**
* --------------------------------------------------------------------------
* Web Page Caching: Cache Status Codes
* --------------------------------------------------------------------------
*
* HTTP status codes that are allowed to be cached. Only responses with
* these status codes will be cached by the PageCache filter.
*
* Default: [] - Cache all status codes (backward compatible)
*
* Recommended: [200] - Only cache successful responses
*
* You can also use status codes like:
* [200, 404, 410] - Cache successful responses and specific error codes
* [200, 201, 202, 203, 204] - All 2xx successful responses
*
* WARNING: Using [] may cache temporary error pages (404, 500, etc).
* Consider restricting to [200] for production applications to avoid
* caching errors that should be temporary.
*
* @var list<int>
*/
public array $cacheStatusCodes = [];
}

View File

@@ -169,3 +169,8 @@ const MAX_PRECISION = 1e14;
const DEFAULT_PRECISION = 2;
const DEFAULT_LANGUAGE = 'english';
const DEFAULT_LANGUAGE_CODE = 'en';
/**
* Admin modules - list of modules required for admin privileges
*/
const ADMIN_MODULES = ['customers', 'employees', 'giftcards', 'items', 'item_kits', 'messages', 'receivings', 'reports', 'sales', 'config', 'suppliers'];

View File

@@ -30,6 +30,11 @@ class ContentSecurityPolicy extends BaseConfig
*/
public ?string $reportURI = null;
/**
* Specifies a reporting endpoint to which violation reports ought to be sent.
*/
public ?string $reportTo = null;
/**
* Instructs user agents to rewrite URL schemes, changing
* HTTP to HTTPS. This directive is for websites with
@@ -38,12 +43,12 @@ class ContentSecurityPolicy extends BaseConfig
public bool $upgradeInsecureRequests = false;
// -------------------------------------------------------------------------
// Sources allowed
// CSP DIRECTIVES SETTINGS
// NOTE: once you set a policy to 'none', it cannot be further restricted
// -------------------------------------------------------------------------
/**
* Will default to self if not overridden
* Will default to `'self'` if not overridden
*
* @var list<string>|string|null
*/
@@ -64,6 +69,21 @@ class ContentSecurityPolicy extends BaseConfig
'www.google.com www.gstatic.com'
];
/**
* Specifies valid sources for JavaScript <script> elements.
*
* @var list<string>|string
*/
public array|string $scriptSrcElem = 'self';
/**
* Specifies valid sources for JavaScript inline event
* handlers and JavaScript URLs.
*
* @var list<string>|string
*/
public array|string $scriptSrcAttr = 'self';
/**
* Lists allowed stylesheets' URLs.
*
@@ -76,6 +96,21 @@ class ContentSecurityPolicy extends BaseConfig
'https://fonts.googleapis.com',
];
/**
* Specifies valid sources for stylesheets <link> elements.
*
* @var list<string>|string
*/
public array|string $styleSrcElem = 'self';
/**
* Specifies valid sources for stylesheets inline
* style attributes and `<style>` elements.
*
* @var list<string>|string
*/
public array|string $styleSrcAttr = 'self';
/**
* Defines the origins from which images can be loaded.
*
@@ -169,6 +204,11 @@ class ContentSecurityPolicy extends BaseConfig
*/
public $manifestSrc;
/**
* @var list<string>|string
*/
public array|string $workerSrc = [];
/**
* Limits the kinds of plugins a page may invoke.
*
@@ -184,17 +224,17 @@ class ContentSecurityPolicy extends BaseConfig
public $sandbox;
/**
* Nonce tag for style
* Nonce placeholder for style tags.
*/
public string $styleNonceTag = '{csp-style-nonce}';
/**
* Nonce tag for script
* Nonce placeholder for script tags.
*/
public string $scriptNonceTag = '{csp-script-nonce}';
/**
* Replace nonce tag automatically
* Replace nonce tag automatically?
*/
public bool $autoNonce = true;
}

View File

@@ -85,7 +85,7 @@ class Cookie extends BaseConfig
* (empty string) means default SameSite attribute set by browsers (`Lax`)
* will be set on cookies. If set to `None`, `$secure` must also be set.
*
* @phpstan-var 'None'|'Lax'|'Strict'|''
* @var ''|'Lax'|'None'|'Strict'
*/
public string $samesite = 'Lax';

View File

@@ -42,6 +42,8 @@ class Database extends Config
'strictOn' => false,
'failover' => [],
'port' => 3306,
'numberNative' => false,
'foundRows' => false,
'dateFormat' => [
'date' => 'Y-m-d',
'datetime' => 'Y-m-d H:i:s',
@@ -55,26 +57,27 @@ class Database extends Config
* @var array<string, mixed>
*/
public array $tests = [
'DSN' => '',
'hostname' => 'localhost',
'username' => 'admin',
'password' => 'pointofsale',
'database' => 'ospos',
'DBDriver' => 'MySQLi',
'DBPrefix' => 'ospos_',
'pConnect' => false,
'DBDebug' => (ENVIRONMENT !== 'production'),
'charset' => 'utf8mb4',
'DBCollat' => 'utf8mb4_general_ci',
'swapPre' => '',
'encrypt' => false,
'compress' => false,
'strictOn' => false,
'failover' => [],
'port' => 3306,
'foreignKeys' => true,
'busyTimeout' => 1000,
'dateFormat' => [
'DSN' => '',
'hostname' => 'localhost',
'username' => 'admin',
'password' => 'pointofsale',
'database' => 'ospos',
'DBDriver' => 'MySQLi',
'DBPrefix' => 'ospos_',
'pConnect' => false,
'DBDebug' => (ENVIRONMENT !== 'production'),
'charset' => 'utf8mb4',
'DBCollat' => 'utf8mb4_general_ci',
'swapPre' => '',
'encrypt' => false,
'compress' => false,
'strictOn' => false,
'failover' => [],
'port' => 3306,
'foreignKeys' => true,
'busyTimeout' => 1000,
'synchronous' => null,
'dateFormat' => [
'date' => 'Y-m-d',
'datetime' => 'Y-m-d H:i:s',
'time' => 'H:i:s',

View File

@@ -2,9 +2,6 @@
namespace Config;
/**
* @immutable
*/
class DocTypes
{
/**

View File

@@ -30,6 +30,11 @@ class Email extends BaseConfig
*/
public string $SMTPHost = 'mail.mxserver.com';
/**
* Which SMTP authentication method to use: login, plain
*/
public string $SMTPAuthMethod = 'login';
/**
* SMTP Username
*/

View File

@@ -23,6 +23,23 @@ class Encryption extends BaseConfig
*/
public string $key = '';
/**
* --------------------------------------------------------------------------
* Previous Encryption Keys
* --------------------------------------------------------------------------
*
* When rotating encryption keys, add old keys here to maintain ability
* to decrypt data encrypted with previous keys. Encryption always uses
* the current $key. Decryption tries current key first, then falls back
* to previous keys if decryption fails.
*
* In .env file, use comma-separated string:
* encryption.previousKeys = hex2bin:9be8c64fcea509867...,hex2bin:3f5a1d8e9c2b7a4f6...
*
* @var list<string>|string
*/
public array|string $previousKeys = '';
/**
* --------------------------------------------------------------------------
* Encryption Driver to Use

View File

@@ -65,12 +65,15 @@ class Filters extends BaseFilters
* List of filter aliases that are always
* applied before and after every request.
*
* @var array<string, array<string, array<string, string>>>|array<string, list<string>>
* @var array{
* before: array<string, array{except: list<string>|string}>|list<string>,
* after: array<string, array{except: list<string>|string}>|list<string>
* }
*/
public array $globals = [
'before' => [
'honeypot',
// 'csrf' => ['except' => 'login'], // TODO: Temporarily disable CSRF until we get everything sorted
'csrf' => ['except' => 'login|migrate'],
'invalidchars',
],
'after' => [
@@ -105,4 +108,20 @@ class Filters extends BaseFilters
* @var array<string, array<string, list<string>>>
*/
public array $filters = [];
/**
* Constructor to conditionally disable CSRF filter in testing environment
*/
public function __construct()
{
// Check for testing environment via env variable or constant
$isTesting = ($_ENV['CI_ENVIRONMENT'] ?? $_SERVER['CI_ENVIRONMENT'] ?? getenv('CI_ENVIRONMENT')) === 'testing'
|| (defined('ENVIRONMENT') && ENVIRONMENT === 'testing');
// Remove CSRF filter from globals in testing environment
if ($isTesting) {
// Remove the 'csrf' key from $globals['before'] while preserving array structure
$this->globals['before'] = array_filter($this->globals['before'], static fn($key) => $key !== 'csrf', ARRAY_FILTER_USE_KEY);
}
}
}

View File

@@ -61,4 +61,13 @@ class Format extends BaseConfig
'application/xml' => 0,
'text/xml' => 0,
];
/**
* --------------------------------------------------------------------------
* Maximum depth for JSON encoding.
* --------------------------------------------------------------------------
*
* This value determines how deep the JSON encoder will traverse nested structures.
*/
public int $jsonEncodeDepth = 512;
}

40
app/Config/Hostnames.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
namespace Config;
class Hostnames
{
// List of known two-part TLDs for subdomain extraction
public const TWO_PART_TLDS = [
'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'sch.uk', 'ltd.uk', 'plc.uk',
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',
'co.jp', 'ac.jp', 'go.jp', 'or.jp', 'ne.jp', 'gr.jp',
'co.nz', 'org.nz', 'govt.nz', 'ac.nz', 'net.nz', 'geek.nz', 'maori.nz', 'school.nz',
'co.in', 'net.in', 'org.in', 'ind.in', 'ac.in', 'gov.in', 'res.in',
'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn',
'com.sg', 'net.sg', 'org.sg', 'gov.sg', 'edu.sg', 'per.sg',
'co.za', 'org.za', 'gov.za', 'ac.za', 'net.za',
'co.kr', 'or.kr', 'go.kr', 'ac.kr', 'ne.kr', 'pe.kr',
'co.th', 'or.th', 'go.th', 'ac.th', 'net.th', 'in.th',
'com.my', 'net.my', 'org.my', 'edu.my', 'gov.my', 'mil.my', 'name.my',
'com.mx', 'org.mx', 'net.mx', 'edu.mx', 'gob.mx',
'com.br', 'net.br', 'org.br', 'gov.br', 'edu.br', 'art.br', 'eng.br',
'co.il', 'org.il', 'ac.il', 'gov.il', 'net.il', 'muni.il',
'co.id', 'or.id', 'ac.id', 'go.id', 'net.id', 'web.id', 'my.id',
'com.hk', 'edu.hk', 'gov.hk', 'idv.hk', 'net.hk', 'org.hk',
'com.tw', 'net.tw', 'org.tw', 'edu.tw', 'gov.tw', 'idv.tw',
'com.sa', 'net.sa', 'org.sa', 'gov.sa', 'edu.sa', 'sch.sa', 'med.sa',
'co.ae', 'net.ae', 'org.ae', 'gov.ae', 'ac.ae', 'sch.ae',
'com.tr', 'net.tr', 'org.tr', 'gov.tr', 'edu.tr', 'av.tr', 'gen.tr',
'co.ke', 'or.ke', 'go.ke', 'ac.ke', 'sc.ke', 'me.ke', 'mobi.ke', 'info.ke',
'com.ng', 'org.ng', 'gov.ng', 'edu.ng', 'net.ng', 'sch.ng', 'name.ng',
'com.pk', 'net.pk', 'org.pk', 'gov.pk', 'edu.pk', 'fam.pk',
'com.eg', 'edu.eg', 'gov.eg', 'org.eg', 'net.eg',
'com.cy', 'net.cy', 'org.cy', 'gov.cy', 'ac.cy',
'com.lk', 'org.lk', 'edu.lk', 'gov.lk', 'net.lk', 'int.lk',
'com.bd', 'net.bd', 'org.bd', 'ac.bd', 'gov.bd', 'mil.bd',
'com.ar', 'net.ar', 'org.ar', 'gov.ar', 'edu.ar', 'mil.ar',
'gob.cl', 'com.pl', 'net.pl', 'org.pl', 'gov.pl', 'edu.pl',
'co.ir', 'ac.ir', 'org.ir', 'id.ir', 'gov.ir', 'sch.ir', 'net.ir',
];
}

View File

@@ -16,6 +16,8 @@ class Images extends BaseConfig
/**
* The path to the image library.
* Required for ImageMagick, GraphicsMagick, or NetPBM.
*
* @deprecated 4.7.0 No longer used.
*/
public string $libraryPath = '/usr/local/bin/convert';

View File

@@ -4,6 +4,7 @@ namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Log\Handlers\FileHandler;
use CodeIgniter\Log\Handlers\HandlerInterface;
class Logger extends BaseConfig
{
@@ -73,7 +74,7 @@ class Logger extends BaseConfig
* Handlers are executed in the order defined in this array, starting with
* the handler on top and continuing down.
*
* @var array<class-string, array<string, int|list<string>|string>>
* @var array<class-string<HandlerInterface>, array<string, int|list<string>|string>>
*/
public array $handlers = [
/*

View File

@@ -47,4 +47,19 @@ class Migrations extends BaseConfig
* - Y_m_d_His_
*/
public string $timestampFormat = 'YmdHis_';
/**
* --------------------------------------------------------------------------
* Enable/Disable Migration Lock
* --------------------------------------------------------------------------
*
* Locking is disabled by default.
*
* When enabled, it will prevent multiple migration processes
* from running at the same time by using a lock mechanism.
*
* This is useful in production environments to avoid conflicts
* or race conditions during concurrent deployments.
*/
public bool $lock = false;
}

View File

@@ -3,8 +3,6 @@
namespace Config;
/**
* Mimes
*
* This file contains an array of mime types. It is used by the
* Upload class to help identify allowed file types.
*
@@ -15,8 +13,6 @@ namespace Config;
*
* When working with mime types, please make sure you have the ´fileinfo´
* extension enabled to reliably detect the media types.
*
* @immutable
*/
class Mimes
{
@@ -482,6 +478,8 @@ class Mimes
'application/sla',
'application/vnd.ms-pki.stl',
'application/x-navistyle',
'model/stl',
'application/octet-stream',
],
];
@@ -490,7 +488,7 @@ class Mimes
*
* @return string|null The mime type found, or none if unable to determine.
*/
public static function guessTypeFromExtension(string $extension): array|string|null
public static function guessTypeFromExtension(string $extension)
{
$extension = trim(strtolower($extension), '. ');
@@ -508,7 +506,7 @@ class Mimes
*
* @return string|null The extension determined, or null if unable to match.
*/
public static function guessExtensionFromType(string $type, ?string $proposedExtension = null): ?string
public static function guessExtensionFromType(string $type, ?string $proposedExtension = null)
{
$type = trim(strtolower($type), '. ');

View File

@@ -9,8 +9,6 @@ use CodeIgniter\Modules\Modules as BaseModules;
*
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*
* @immutable
*/
class Modules extends BaseModules
{

View File

@@ -5,6 +5,7 @@ namespace Config;
use App\Models\Appconfig;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Config\BaseConfig;
use Config\Database;
/**
* This class holds the configuration options stored from the database so that on launch those settings can be cached
@@ -13,7 +14,7 @@ use CodeIgniter\Config\BaseConfig;
*/
class OSPOS extends BaseConfig
{
public array $settings;
public array $settings = [];
public string $commit_sha1 = 'dev'; // TODO: Travis scripts need to be updated to replace this with the commit hash on build
private CacheInterface $cache;
@@ -33,15 +34,37 @@ class OSPOS extends BaseConfig
if ($cache) {
$this->settings = decode_array($cache);
} else {
return;
}
try {
$db = Database::connect();
if (!$db->tableExists('app_config')) {
$this->settings = $this->getDefaultSettings();
return;
}
$appconfig = model(Appconfig::class);
foreach ($appconfig->get_all()->getResult() as $app_config) {
$this->settings[$app_config->key] = $app_config->value;
}
$this->cache->save('settings', encode_array($this->settings));
} catch (\Exception $e) {
$this->settings = $this->getDefaultSettings();
}
}
private function getDefaultSettings(): array
{
return [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home',
'barcode_type' => 'Code39'
];
}
/**
* @return void
*/

View File

@@ -8,7 +8,7 @@ namespace Config;
* NOTE: This class does not extend BaseConfig for performance reasons.
* So you cannot replace the property values with Environment Variables.
*
* @immutable
* WARNING: Do not use these options when running the app in the Worker Mode.
*/
class Optimize
{

View File

@@ -15,8 +15,6 @@ namespace Config;
*
* NOTE: This class is required prior to Autoloader instantiation,
* and does not extend BaseConfig.
*
* @immutable
*/
class Paths
{
@@ -77,4 +75,16 @@ class Paths
* is used when no value is provided to `Services::renderer()`.
*/
public string $viewDirectory = __DIR__ . '/../Views';
/**
* ---------------------------------------------------------------
* ENVIRONMENT DIRECTORY NAME
* ---------------------------------------------------------------
*
* This variable must contain the name of the directory where
* the .env file is located.
* Please consider security implications when changing this
* value - the directory should not be publicly accessible.
*/
public string $envDirectory = __DIR__ . '/../../';
}

View File

@@ -10,6 +10,7 @@ $routes->setDefaultController('Login');
$routes->get('/', 'Login::index');
$routes->get('login', 'Login::index');
$routes->post('login', 'Login::index');
$routes->post('migrate', 'Login::migrate');
$routes->add('no_access/index/(:segment)', 'No_access::index/$1');
$routes->add('no_access/index/(:segment)/(:segment)', 'No_access::index/$1/$2');

View File

@@ -96,6 +96,15 @@ class Routing extends BaseRouting
*/
public bool $autoRoute = true;
/**
* If TRUE, the system will look for attributes on controller
* class and methods that can run before and after the
* controller/method.
*
* If FALSE, will ignore any attributes.
*/
public bool $useControllerAttributes = true;
/**
* For Defined Routes.
* If TRUE, will enable the use of the 'prioritize' option

View File

@@ -15,7 +15,7 @@ class Security extends BaseConfig
*
* @var string 'cookie' or 'session'
*/
public string $csrfProtection = 'cookie';
public string $csrfProtection = 'session';
/**
* --------------------------------------------------------------------------
@@ -71,7 +71,7 @@ class Security extends BaseConfig
*
* Regenerate CSRF Token on every submission.
*/
public bool $regenerate = true;
public bool $regenerate = false;
/**
* --------------------------------------------------------------------------

View File

@@ -2,11 +2,13 @@
namespace Config;
use CodeIgniter\Config\BaseService;
use CodeIgniter\HTTP\IncomingRequest;
use Config\Services as AppServices;
use App\Libraries\MY_Language;
use Locale;
use HTMLPurifier;
use HTMLPurifier_Config;
use CodeIgniter\Config\BaseService;
use Config\Services as AppServices;
use CodeIgniter\HTTP\IncomingRequest;
/**
* Services Configuration file.
@@ -37,9 +39,11 @@ class Services extends BaseService
/**
* Responsible for loading the language string translations.
*
* @param string|null $locale
* @param bool $getShared
* @return MY_Language
*/
public static function language(?string $locale = null, bool $getShared = true)
public static function language(?string $locale = null, bool $getShared = true): MY_Language
{
if ($getShared) {
return static::getSharedInstance('language', $locale)->setLocale($locale);
@@ -54,12 +58,12 @@ class Services extends BaseService
// Use '?:' for empty string check
$locale = $locale ?: $requestLocale;
return new \App\Libraries\MY_Language($locale);
return new MY_Language($locale);
}
private static $htmlPurifier;
private static HTMLPurifier $htmlPurifier;
public static function htmlPurifier($getShared = true)
public static function htmlPurifier($getShared = true): object
{
if ($getShared) {
return static::getSharedInstance('htmlPurifier');

View File

@@ -5,6 +5,7 @@ namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Session\Handlers\BaseHandler;
use CodeIgniter\Session\Handlers\DatabaseHandler;
use CodeIgniter\Session\Handlers\FileHandler;
class Session extends BaseConfig
{
@@ -124,4 +125,27 @@ class Session extends BaseConfig
* seconds.
*/
public int $lockMaxRetries = 300;
public function __construct()
{
parent::__construct();
if ($this->driver === DatabaseHandler::class) {
try {
$db = Database::connect();
if (!$db->tableExists($this->savePath)) {
$this->driver = FileHandler::class;
$this->savePath = WRITEPATH . 'session';
}
} catch (\Exception $e) {
// Database not available yet (e.g. fresh install before migrations).
// Fall back to file-based sessions so the login/migration page
// can still be served. Catches mysqli_sql_exception which is
// not a subclass of DatabaseException but is a RuntimeException.
$this->driver = FileHandler::class;
$this->savePath = WRITEPATH . 'session';
}
}
}
}

View File

@@ -119,4 +119,29 @@ class Toolbar extends BaseConfig
public array $watchedExtensions = [
'php', 'css', 'js', 'html', 'svg', 'json', 'env',
];
/**
* --------------------------------------------------------------------------
* Ignored HTTP Headers
* --------------------------------------------------------------------------
*
* CodeIgniter Debug Toolbar normally injects HTML and JavaScript into every
* HTML response. This is correct for full page loads, but it breaks requests
* that expect only a clean HTML fragment.
*
* Libraries like HTMX, Unpoly, and Hotwire (Turbo) update parts of the page or
* manage navigation on the client side. Injecting the Debug Toolbar into their
* responses can cause invalid HTML, duplicated scripts, or JavaScript errors
* (such as infinite loops or "Maximum call stack size exceeded").
*
* Any request containing one of the following headers is treated as a
* client-managed or partial request, and the Debug Toolbar injection is skipped.
*
* @var array<string, string|null>
*/
public array $disableOnHeaders = [
'X-Requested-With' => 'xmlhttprequest', // AJAX requests
'HX-Request' => 'true', // HTMX requests
'X-Up-Version' => null, // Unpoly partial requests
];
}

View File

@@ -230,9 +230,13 @@ class UserAgents extends BaseConfig
*/
public array $robots = [
'googlebot' => 'Googlebot',
'google-pagerenderer' => 'Google Page Renderer',
'google-read-aloud' => 'Google Read Aloud',
'google-safety' => 'Google Safety Bot',
'msnbot' => 'MSNBot',
'baiduspider' => 'Baiduspider',
'bingbot' => 'Bing',
'bingpreview' => 'BingPreview',
'slurp' => 'Inktomi Slurp',
'yahoo' => 'Yahoo',
'ask jeeves' => 'Ask Jeeves',
@@ -248,5 +252,11 @@ class UserAgents extends BaseConfig
'ia_archiver' => 'Alexa Crawler',
'MJ12bot' => 'Majestic-12',
'Uptimebot' => 'Uptimebot',
'duckduckbot' => 'DuckDuckBot',
'sogou' => 'Sogou Spider',
'exabot' => 'Exabot',
'bot' => 'Generic Bot',
'crawler' => 'Generic Crawler',
'spider' => 'Generic Spider',
];
}

View File

@@ -135,4 +135,19 @@ class OSPOSRules
{
return parse_decimals($candidate) !== false;
}
/**
* Validates that a locale-aware decimal value is non-negative (>= 0).
*
* @param string $candidate
* @param string|null $error
* @return bool
* @noinspection PhpUnused
*/
public function nonNegativeDecimal(string $candidate, ?string &$error = null): bool
{
$value = parse_decimals($candidate);
return $value !== false && $value >= 0;
}
}

View File

@@ -59,4 +59,21 @@ class View extends BaseView
* @var list<class-string<ViewDecoratorInterface>>
*/
public array $decorators = [];
/**
* Subdirectory within app/Views for namespaced view overrides.
*
* Namespaced views will be searched in:
*
* app/Views/{$appOverridesFolder}/{Namespace}/{view_path}.{php|html...}
*
* This allows application-level overrides for package or module views
* without modifying vendor source files.
*
* Examples:
* 'overrides' -> app/Views/overrides/Example/Blog/post/card.php
* 'vendor' -> app/Views/vendor/Example/Blog/post/card.php
* '' -> app/Views/Example/Blog/post/card.php (direct mapping)
*/
public string $appOverridesFolder = 'overrides';
}

62
app/Config/WorkerMode.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
namespace Config;
/**
* This configuration controls how CodeIgniter behaves when running
* in worker mode (with FrankenPHP).
*/
class WorkerMode
{
/**
* Persistent Services
*
* List of service names that should persist across requests.
* These services will NOT be reset between requests.
*
* Services not in this list will be reset for each request to prevent
* state leakage.
*
* Recommended persistent services:
* - `autoloader`: PSR-4 autoloading configuration
* - `locator`: File locator
* - `exceptions`: Exception handler
* - `commands`: CLI commands registry
* - `codeigniter`: Main application instance
* - `superglobals`: Superglobals wrapper
* - `routes`: Router configuration
* - `cache`: Cache instance
*
* @var list<string>
*/
public array $persistentServices = [
'autoloader',
'locator',
'exceptions',
'commands',
'codeigniter',
'superglobals',
'routes',
'cache',
];
/**
* Reset Event Listeners
*
* List of event names whose listeners should be removed between requests.
* Use this if you register event listeners inside other event callbacks
* (rather than at the top level of Config/Events.php), which would cause
* them to accumulate across requests in worker mode.
*
* @var list<string>
*/
public array $resetEventListeners = [];
/**
* Force Garbage Collection
*
* Whether to force garbage collection after each request.
* Helps prevent memory leaks at a small performance cost.
*/
public bool $forceGarbageCollection = true;
}

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Attribute;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
require_once('Secure_Controller.php');
@@ -24,19 +25,19 @@ class Attributes extends Secure_Controller
/**
* Gets and sends the main view for Attributes to the browser.
*
* @return void
* @return string
**/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_attribute_definition_manage_table_headers();
echo view('attributes/manage', $data);
return view('attributes/manage', $data);
}
/**
* Returns attribute table data rows. This will be called with AJAX.
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
@@ -53,15 +54,15 @@ class Attributes extends Secure_Controller
$data_rows[] = get_attribute_definition_data_row($attribute_row);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* AJAX called function which saves the attribute value sent via POST by using the model save function.
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveAttributeValue(): void
public function postSaveAttributeValue(): ResponseInterface
{
$success = $this->attribute->saveAttributeValue(
html_entity_decode($this->request->getPost('attribute_value')),
@@ -70,32 +71,32 @@ class Attributes extends Secure_Controller
$this->request->getPost('attribute_id', FILTER_SANITIZE_NUMBER_INT) ?? false
);
echo json_encode(['success' => $success != 0]);
return $this->response->setJSON(['success' => $success != 0]);
}
/**
* AJAX called function deleting an attribute value using the model delete function.
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postDeleteDropdownAttributeValue(): void
public function postDeleteDropdownAttributeValue(): ResponseInterface
{
$success = $this->attribute->deleteDropdownAttributeValue(
html_entity_decode($this->request->getPost('attribute_value')),
$this->request->getPost('definition_id', FILTER_SANITIZE_NUMBER_INT)
);
echo json_encode(['success' => $success]);
return $this->response->setJSON(['success' => $success]);
}
/**
* AJAX called function which saves the attribute definition.
*
* @param int $definition_id
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveDefinition(int $definition_id = NO_DEFINITION_ID): void
public function postSaveDefinition(int $definition_id = NO_DEFINITION_ID): ResponseInterface
{
$definition_flags = 0;
@@ -105,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) {
@@ -119,7 +132,7 @@ class Attributes extends Secure_Controller
$definition_name = $definition_data['definition_name'];
if ($this->attribute->save_definition($definition_data, $definition_id)) {
if ($this->attribute->saveDefinition($definition_data, $definition_id)) {
// New definition
if ($definition_id == NO_DEFINITION_ID) {
$definition_values = json_decode(html_entity_decode($this->request->getPost('definition_values')));
@@ -128,20 +141,20 @@ class Attributes extends Secure_Controller
$this->attribute->saveAttributeValue($definition_value, $definition_data['definition_id']);
}
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Attributes.definition_successful_adding') . ' ' . $definition_name,
'id' => $definition_data['definition_id']
]);
} else { // Existing definition
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Attributes.definition_successful_updating') . ' ' . $definition_name,
'id' => $definition_id
]);
}
} else { // Failure
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Attributes.definition_error_adding_updating', [$definition_name]),
'id' => NEW_ENTRY
@@ -149,30 +162,56 @@ 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
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getSuggestAttribute(int $definition_id): void
public function getSuggestAttribute(int $definition_id): ResponseInterface
{
$suggestions = $this->attribute->get_suggestions($definition_id, html_entity_decode($this->request->getGet('term')));
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* @param int $row_id
* @return void
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$attribute_definition_info = $this->attribute->getAttributeInfo($row_id);
$attribute_definition_info->definition_flags = $this->get_attributes($attribute_definition_info->definition_flags);
$data_row = get_attribute_definition_data_row($attribute_definition_info);
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
@@ -192,9 +231,9 @@ class Attributes extends Secure_Controller
/**
* @param int $definition_id
* @return void
* @return string
*/
public function getView(int $definition_id = NO_DEFINITION_ID): void
public function getView(int $definition_id = NO_DEFINITION_ID): string
{
$info = $this->attribute->getAttributeInfo($definition_id);
foreach (get_object_vars($info) as $property => $value) {
@@ -212,22 +251,22 @@ class Attributes extends Secure_Controller
$selected_flags = $info->definition_flags === '' ? $show_all : $info->definition_flags;
$data['selected_definition_flags'] = $this->get_attributes($selected_flags);
echo view('attributes/form', $data);
return view('attributes/form', $data);
}
/**
* Deletes an attribute definition
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$attributes_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if($this->attribute->deleteDefinitionList($attributes_to_delete)) {
$message = lang('Attributes.definition_successful_deleted') . ' ' . count($attributes_to_delete) . ' ' . lang('Attributes.definition_one_or_multiple');
echo json_encode(['success' => true, 'message' => $message]);
return $this->response->setJSON(['success' => true, 'message' => $message]);
} else {
echo json_encode(['success' => false, 'message' => lang('Attributes.definition_cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Attributes.definition_cannot_be_deleted')]);
}
}
}

View File

@@ -3,44 +3,28 @@
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\CLIRequest;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
/**
* Class BaseController
*
* BaseController provides a convenient place for loading components
* and performing functions that are needed by all your controllers.
* Extend this class in any new controllers:
* class Home extends BaseController
*
* For security be sure to declare any new methods as protected or private.
* Extend this class in any new controllers:
* ```
* class Home extends BaseController
* ```
*
* For security, be sure to declare any new methods as protected or private.
*/
abstract class BaseController extends Controller
{
/**
* Instance of the main Request object.
*
* @var CLIRequest|IncomingRequest
*/
protected $request;
/**
* An array of helpers to be loaded automatically upon
* class instantiation. These helpers will be available
* to all other controllers that extend BaseController.
*
* @var list<string>
*/
protected $helpers = [];
/**
* Be sure to declare properties for any property fetch you initialized.
* The creation of dynamic property is deprecated in PHP 8.2.
*/
// protected $session;
/**
@@ -48,11 +32,14 @@ abstract class BaseController extends Controller
*/
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
// Do Not Edit This Line
// Load here all helpers you want to be available in your controllers that extend BaseController.
// Caution: Do not put the this below the parent::initController() call below.
// $this->helpers = ['form', 'url'];
// Caution: Do not edit this line.
parent::initController($request, $response, $logger);
// Preload any models, libraries, etc, here.
// E.g.: $this->session = service('session');
// $this->session = service('session');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Controllers;
use App\Models\Cashup;
use App\Models\Expense;
use App\Models\Reports\Summary_payments;
use CodeIgniter\HTTP\ResponseInterface;
use Config\OSPOS;
use Config\Services;
@@ -26,22 +27,25 @@ class Cashups extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_cashups_manage_table_headers();
// filters that will be loaded in the multiselect dropdown
$data['filters'] = ['is_deleted' => lang('Cashups.is_deleted')];
echo view('cashups/manage', $data);
// Restore filters from URL
$data = array_merge($data, restoreTableFilters($this->request));
return view('cashups/manage', $data);
}
/**
* @return void
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
@@ -64,14 +68,14 @@ class Cashups extends Secure_Controller
$data_rows[] = get_cash_up_data_row($cash_up);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* @param int $cashup_id
* @return void
* @return string
*/
public function getView(int $cashup_id = NEW_ENTRY): void
public function getView(int $cashup_id = NEW_ENTRY): string
{
$data = [];
@@ -180,26 +184,26 @@ class Cashups extends Secure_Controller
$data['cash_ups_info'] = $cash_ups_info;
echo view("cashups/form", $data);
return view("cashups/form", $data);
}
/**
* @param int $row_id
* @return void
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$cash_ups_info = $this->cashup->get_info($row_id);
$data_row = get_cash_up_data_row($cash_ups_info);
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
* @param int $cashup_id
* @return void
* @return ResponseInterface
*/
public function postSave(int $cashup_id = NEW_ENTRY): void
public function postSave(int $cashup_id = NEW_ENTRY): ResponseInterface
{
$open_date = $this->request->getPost('open_date');
$open_date_formatter = date_create_from_format($this->config['dateformat'] . ' ' . $this->config['timeformat'], $open_date);
@@ -227,36 +231,36 @@ class Cashups extends Secure_Controller
if ($this->cashup->save_value($cash_up_data, $cashup_id)) {
// New cashup_id
if ($cashup_id == NEW_ENTRY) {
echo json_encode(['success' => true, 'message' => lang('Cashups.successful_adding'), 'id' => $cash_up_data['cashup_id']]);
return $this->response->setJSON(['success' => true, 'message' => lang('Cashups.successful_adding'), 'id' => $cash_up_data['cashup_id']]);
} else { // Existing Cashup
echo json_encode(['success' => true, 'message' => lang('Cashups.successful_updating'), 'id' => $cashup_id]);
return $this->response->setJSON(['success' => true, 'message' => lang('Cashups.successful_updating'), 'id' => $cashup_id]);
}
} else { // Failure
echo json_encode(['success' => false, 'message' => lang('Cashups.error_adding_updating'), 'id' => NEW_ENTRY]);
return $this->response->setJSON(['success' => false, 'message' => lang('Cashups.error_adding_updating'), 'id' => NEW_ENTRY]);
}
}
/**
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$cash_ups_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if ($this->cashup->delete_list($cash_ups_to_delete)) {
echo json_encode(['success' => true, 'message' => lang('Cashups.successful_deleted') . ' ' . count($cash_ups_to_delete) . ' ' . lang('Cashups.one_or_multiple'), 'ids' => $cash_ups_to_delete]);
return $this->response->setJSON(['success' => true, 'message' => lang('Cashups.successful_deleted') . ' ' . count($cash_ups_to_delete) . ' ' . lang('Cashups.one_or_multiple'), 'ids' => $cash_ups_to_delete]);
} else {
echo json_encode(['success' => false, 'message' => lang('Cashups.cannot_be_deleted'), 'ids' => $cash_ups_to_delete]);
return $this->response->setJSON(['success' => false, 'message' => lang('Cashups.cannot_be_deleted'), 'ids' => $cash_ups_to_delete]);
}
}
/**
* Calculate the total for cashups. Used in app\Views\cashups\form.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postAjax_cashup_total(): void
public function postAjax_cashup_total(): ResponseInterface
{
$open_amount_cash = parse_decimals($this->request->getPost('open_amount_cash'));
$transfer_amount_cash = parse_decimals($this->request->getPost('transfer_amount_cash'));
@@ -267,7 +271,7 @@ class Cashups extends Secure_Controller
$total = $this->_calculate_total($open_amount_cash, $transfer_amount_cash, $closed_amount_due, $closed_amount_cash, $closed_amount_card, $closed_amount_check); // TODO: hungarian notation
echo json_encode(['total' => to_currency_no_money($total)]);
return $this->response->setJSON(['total' => to_currency_no_money($total)]);
}
/**

View File

@@ -11,12 +11,14 @@ 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;
use App\Models\Tax;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Encryption\EncrypterInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Database;
use Config\OSPOS;
use Config\Services;
@@ -80,7 +82,7 @@ class Config extends Secure_Controller
$npmDev = false;
$license = [];
$license[$i]['title'] = 'Open Source Point Of Sale ' . config('App')->application_version;
$license[$i]['title'] = 'Open Source Point of Sale ' . config('App')->application_version;
if (file_exists('license/LICENSE')) {
$license[$i]['text'] = file_get_contents('license/LICENSE', false, null, 0, 3000);
@@ -215,18 +217,23 @@ class Config extends Secure_Controller
}
/**
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['config'] = $this->config;
$data['stock_locations'] = $this->stock_location->get_all()->getResultArray();
$data['dinner_tables'] = $this->dinner_table->get_all()->getResultArray();
$data['customer_rewards'] = $this->customer_rewards->get_all()->getResultArray();
$data['support_barcode'] = $this->barcode_lib->get_list_barcodes();
$data['barcode_fonts'] = $this->barcode_lib->listfonts('fonts');
$data['logo_exists'] = $this->config['company_logo'] != '';
$data['logo_src'] = !empty($this->config['company_logo']) ? base_url('uploads/' . $this->config['company_logo']) : '';
$data['line_sequence_options'] = $this->sale_lib->get_line_sequence_options();
$data['register_mode_options'] = $this->sale_lib->get_register_mode_options();
$data['invoice_type_options'] = $this->sale_lib->get_invoice_type_options();
$data['keyboardShortcutOptions'] = $this->sale_lib->getKeyShortcutsOptions();
$data['keyboardShortcuts'] = $this->sale_lib->getKeyShortcuts();
$data['rounding_options'] = rounding_mode::get_rounding_options();
$data['tax_code_options'] = $this->tax_lib->get_tax_code_options();
$data['tax_category_options'] = $this->tax_lib->get_tax_category_options();
@@ -272,17 +279,17 @@ class Config extends Secure_Controller
$data['mailchimp']['lists'] = $this->_mailchimp();
echo view('configs/manage', $data);
return view('configs/manage', $data);
}
/**
* Saves company information. Used in app/Views/configs/info_config.php
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveInfo(): void
public function postSaveInfo(): ResponseInterface
{
$upload_data = $this->upload_logo();
$upload_success = empty($upload_data['error']);
@@ -306,7 +313,7 @@ class Config extends Secure_Controller
$message = lang('Config.saved_' . ($success ? '' : 'un') . 'successfully');
$message = $upload_success ? $message : strip_tags($upload_data['error']);
echo json_encode(['success' => $success, 'message' => $message]);
return $this->response->setJSON(['success' => $success, 'message' => $message]);
}
@@ -358,11 +365,12 @@ class Config extends Secure_Controller
* Saves general configuration. Used in app/Views/configs/general_config.php
*
* @throws ReflectionException
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveGeneral(): void
public function postSaveGeneral(): ResponseInterface
{
$batch_save_data = [
$batchSaveData = [
'theme' => $this->request->getPost('theme'),
'login_form' => $this->request->getPost('login_form'),
'default_sales_discount_type' => $this->request->getPost('default_sales_discount_type') != null,
@@ -381,9 +389,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,
@@ -393,57 +401,67 @@ class Config extends Secure_Controller
$this->module->set_show_office_group($this->request->getPost('show_office_group') != null);
if ($batch_save_data['category_dropdown'] == 1) {
$definition_data['definition_name'] = 'ospos_category';
$definition_data['definition_flags'] = 0;
$definition_data['definition_type'] = 'DROPDOWN';
$definition_data['definition_id'] = CATEGORY_DEFINITION_ID;
$definition_data['deleted'] = 0;
$this->db->transStart();
$this->attribute->save_definition($definition_data, CATEGORY_DEFINITION_ID);
} elseif ($batch_save_data['category_dropdown'] == NO_DEFINITION_ID) {
$this->attribute->deleteDefinition(CATEGORY_DEFINITION_ID);
$attributeSuccess = true;
if ($batchSaveData['category_dropdown']) {
$definitionData['definition_name'] = 'ospos_category';
$definitionData['definition_flags'] = 0;
$definitionData['definition_type'] = 'DROPDOWN';
$definitionData['definition_id'] = CATEGORY_DEFINITION_ID;
$definitionData['deleted'] = 0;
$attributeSuccess = $this->attribute->saveDefinition($definitionData, CATEGORY_DEFINITION_ID);
} elseif ($batchSaveData['category_dropdown'] == NO_DEFINITION_ID) {
$attributeSuccess = $this->attribute->deleteDefinition(CATEGORY_DEFINITION_ID);
}
$success = $this->appconfig->batch_save($batch_save_data);
$success = $attributeSuccess && $this->appconfig->batch_save($batchSaveData);
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
$this->db->transComplete();
$success = $success && $this->db->transStatus();
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Checks a number against the currently selected locale. Used in app/Views/configs/locale_config.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postCheckNumberLocale(): void
public function postCheckNumberLocale(): ResponseInterface
{
$number_locale = $this->request->getPost('number_locale');
$save_number_locale = $this->request->getPost('save_number_locale');
$numberLocale = $this->request->getPost('number_locale');
$saveNumberLocale = $this->request->getPost('save_number_locale');
$postedCurrencySymbol = $this->request->getPost('currency_symbol');
$postedCurrencyCode = $this->request->getPost('currency_code');
$fmt = new NumberFormatter($number_locale, NumberFormatter::CURRENCY);
if ($number_locale != $save_number_locale) {
$currency_symbol = $fmt->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
$currency_code = $fmt->getTextAttribute(NumberFormatter::CURRENCY_CODE);
$save_number_locale = $number_locale;
} else {
$currency_symbol = empty($this->request->getPost('currency_symbol')) ? $fmt->getSymbol(NumberFormatter::CURRENCY_SYMBOL) : $this->request->getPost('currency_symbol');
$currency_code = empty($this->request->getPost('currency_code')) ? $fmt->getTextAttribute(NumberFormatter::CURRENCY_CODE) : $this->request->getPost('currency_code');
$fmt = new NumberFormatter($numberLocale, NumberFormatter::CURRENCY);
// Use posted values if provided, otherwise fall back to locale defaults
$currencySymbol = $postedCurrencySymbol !== '' ? $postedCurrencySymbol : $fmt->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
$currencyCode = $postedCurrencyCode !== '' ? $postedCurrencyCode : $fmt->getTextAttribute(NumberFormatter::CURRENCY_CODE);
// Update saved locale if it changed
if ($numberLocale !== $saveNumberLocale) {
$saveNumberLocale = $numberLocale;
}
if ($this->request->getPost('thousands_separator') == 'false') {
$fmt->setTextAttribute(NumberFormatter::GROUPING_SEPARATOR_SYMBOL, '');
}
$fmt->setSymbol(NumberFormatter::CURRENCY_SYMBOL, $currency_symbol);
$number_local_example = $fmt->format(1234567890.12300);
$fmt->setSymbol(NumberFormatter::CURRENCY_SYMBOL, $currencySymbol);
$numberLocaleExample = $fmt->format(1234567890.12300);
echo json_encode([
'success' => $number_local_example != false,
'save_number_locale' => $save_number_locale,
'number_locale_example' => $number_local_example,
'currency_symbol' => $currency_symbol,
'currency_code' => $currency_code,
return $this->response->setJSON([
'success' => $numberLocaleExample != false,
'save_number_locale' => $saveNumberLocale,
'number_locale_example' => $numberLocaleExample,
'currency_symbol' => $currencySymbol,
'currency_code' => $currencyCode,
]);
}
@@ -451,14 +469,15 @@ class Config extends Secure_Controller
* Saves locale configuration. Used in app/Views/configs/locale_config.php
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveLocale(): void
public function postSaveLocale(): ResponseInterface
{
$exploded = explode(":", $this->request->getPost('language'));
$currency_symbol = $this->request->getPost('currency_symbol');
$batch_save_data = [
'currency_symbol' => $this->request->getPost('currency_symbol'),
'currency_symbol' => htmlspecialchars($currency_symbol ?? ''),
'currency_code' => $this->request->getPost('currency_code'),
'language_code' => $exploded[0],
'language' => $exploded[1],
@@ -480,17 +499,17 @@ class Config extends Secure_Controller
$success = $this->appconfig->batch_save($batch_save_data);
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Saves email configuration. Used in app/Views/configs/email_config.php
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveEmail(): void
public function postSaveEmail(): ResponseInterface
{
$password = '';
@@ -498,9 +517,24 @@ class Config extends Secure_Controller
$password = $this->encrypter->encrypt($this->request->getPost('smtp_pass'));
}
$protocol = $this->request->getPost('protocol');
$mailpath = $this->request->getPost('mailpath');
// Validate mailpath: required for sendmail, optional for others but must be safe if provided
$isMailpathRequired = ($protocol === 'sendmail');
$isMailpathProvided = !empty($mailpath);
$isMailpathValid = $isMailpathProvided && preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $mailpath);
if (($isMailpathRequired && !$isMailpathProvided) || ($isMailpathProvided && !$isMailpathValid)) {
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.mailpath_invalid')
]);
}
$batch_save_data = [
'protocol' => $this->request->getPost('protocol'),
'mailpath' => $this->request->getPost('mailpath'),
'protocol' => $protocol,
'mailpath' => $mailpath,
'smtp_host' => $this->request->getPost('smtp_host'),
'smtp_user' => $this->request->getPost('smtp_user'),
'smtp_pass' => $password,
@@ -511,17 +545,17 @@ class Config extends Secure_Controller
$success = $this->appconfig->batch_save($batch_save_data);
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Saves SMS message configuration. Used in app/Views/configs/message_config.php.
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveMessage(): void
public function postSaveMessage(): ResponseInterface
{
$password = '';
@@ -538,7 +572,7 @@ class Config extends Secure_Controller
$success = $this->appconfig->batch_save($batch_save_data);
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
@@ -565,15 +599,15 @@ class Config extends Secure_Controller
/**
* Gets Mailchimp lists when a valid API key is inserted. Used in app/Views/configs/integrations_config.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postCheckMailchimpApiKey(): void
public function postCheckMailchimpApiKey(): ResponseInterface
{
$lists = $this->_mailchimp($this->request->getPost('mailchimp_api_key'));
$success = count($lists) > 0;
echo json_encode([
return $this->response->setJSON([
'success' => $success,
'message' => lang('Config.mailchimp_key_' . ($success ? '' : 'un') . 'successfully'),
'mailchimp_lists' => $lists
@@ -584,10 +618,10 @@ class Config extends Secure_Controller
* Saves Mailchimp configuration. Used in app/Views/configs/integrations_config.php
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveMailchimp(): void
public function postSaveMailchimp(): ResponseInterface
{
$api_key = '';
$list_id = '';
@@ -608,56 +642,56 @@ class Config extends Secure_Controller
$success = $this->appconfig->batch_save($batch_save_data);
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Gets all stock locations. Used in app/Views/configs/stock_config.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function getStockLocations(): void
public function getStockLocations(): string
{
$stock_locations = $this->stock_location->get_all()->getResultArray();
echo view('partial/stock_locations', ['stock_locations' => $stock_locations]);
return view('partial/stock_locations', ['stock_locations' => $stock_locations]);
}
/**
* @return void
* @return string
*/
public function getDinnerTables(): void
public function getDinnerTables(): string
{
$dinner_tables = $this->dinner_table->get_all()->getResultArray();
echo view('partial/dinner_tables', ['dinner_tables' => $dinner_tables]);
return view('partial/dinner_tables', ['dinner_tables' => $dinner_tables]);
}
/**
* Gets all tax categories.
*
* @return void
* @return string
*/
public function ajax_tax_categories(): void // TODO: Is this function called anywhere in the code?
public function ajax_tax_categories(): string // TODO: Is this function called anywhere in the code?
{
$tax_categories = $this->tax->get_all_tax_categories()->getResultArray();
echo view('partial/tax_categories', ['tax_categories' => $tax_categories]);
return view('partial/tax_categories', ['tax_categories' => $tax_categories]);
}
/**
* Gets all customer rewards. Used in app/Views/configs/reward_config.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function getCustomerRewards(): void
public function getCustomerRewards(): string
{
$customer_rewards = $this->customer_rewards->get_all()->getResultArray();
echo view('partial/customer_rewards', ['customer_rewards' => $customer_rewards]);
return view('partial/customer_rewards', ['customer_rewards' => $customer_rewards]);
}
/**
@@ -677,10 +711,10 @@ class Config extends Secure_Controller
/**
* Saves stock locations. Used in app/Views/configs/stock_config.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveLocations(): void
public function postSaveLocations(): ResponseInterface
{
$this->db->transStart();
@@ -712,17 +746,17 @@ class Config extends Secure_Controller
$success = $this->db->transStatus();
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Saves all dinner tables. Used in app/Views/configs/table_config.php
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveTables(): void
public function postSaveTables(): ResponseInterface
{
$this->db->transStart();
@@ -759,17 +793,17 @@ class Config extends Secure_Controller
$success = $this->db->transStatus();
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Saves tax configuration. Used in app/Views/configs/tax_config.php
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveTax(): void
public function postSaveTax(): ResponseInterface
{
$default_tax_1_rate = $this->request->getPost('default_tax_1_rate');
$default_tax_2_rate = $this->request->getPost('default_tax_2_rate');
@@ -791,17 +825,17 @@ class Config extends Secure_Controller
$message = lang('Config.saved_' . ($success ? '' : 'un') . 'successfully');
echo json_encode(['success' => $success, 'message' => $message]);
return $this->response->setJSON(['success' => $success, 'message' => $message]);
}
/**
* Saves customer rewards configuration. Used in app/Views/configs/reward_config.php
*
* @throws ReflectionException
* @return void
* @noinspection PhpUnused
*/
public function postSaveRewards(): void
* @throws ReflectionException
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveRewards(): ResponseInterface
{
$this->db->transStart();
@@ -845,17 +879,17 @@ class Config extends Secure_Controller
$success = $this->db->transStatus();
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Saves barcode configuration. Used in app/Views/configs/barcode_config.php
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveBarcode(): void
public function postSaveBarcode(): ResponseInterface
{
$batch_save_data = [
'barcode_type' => $this->request->getPost('barcode_type'),
@@ -877,20 +911,22 @@ class Config extends Secure_Controller
$success = $this->appconfig->batch_save($batch_save_data);
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Saves receipt configuration. Used in app/Views/configs/receipt_config.php.
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveReceipt(): void
public function postSaveReceipt(): ResponseInterface
{
$batch_save_data = [
'receipt_template' => $this->request->getPost('receipt_template'),
'receipt_template' => Sale_lib::isValidReceiptTemplate($this->request->getPost('receipt_template'))
? $this->request->getPost('receipt_template')
: 'receipt_default',
'receipt_font_size' => $this->request->getPost('receipt_font_size', FILTER_SANITIZE_NUMBER_INT),
'print_delay_autoreturn' => $this->request->getPost('print_delay_autoreturn', FILTER_SANITIZE_NUMBER_INT),
'email_receipt_check_behaviour' => $this->request->getPost('email_receipt_check_behaviour'),
@@ -912,17 +948,55 @@ class Config extends Secure_Controller
$success = $this->appconfig->batch_save($batch_save_data);
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Saves keyboard shortcut bindings.
*
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveShortcuts(): ResponseInterface
{
$allowedShortcuts = array_keys($this->sale_lib->getKeyShortcutsOptions());
$currentShortcuts = $this->sale_lib->getKeyShortcuts();
$batchSaveData = [];
foreach ($currentShortcuts as $name => $shortcut) {
$postedValue = trim((string)$this->request->getPost('key_' . $name));
if (!in_array($postedValue, $allowedShortcuts, true)) {
$postedValue = $shortcut['value'];
}
$batchSaveData['key_' . $name] = $postedValue;
}
$duplicateValues = array_filter(array_count_values($batchSaveData), static fn(int $count): bool => $count > 1);
if (!empty($duplicateValues)) {
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.shortcuts_duplicate_bindings')
]);
}
$success = $this->appconfig->batch_save($batchSaveData);
return $this->response->setJSON([
'success' => $success,
'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')
]);
}
/**
* Saves invoice configuration. Used in app/Views/configs/invoice_config.php.
*
* @throws ReflectionException
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSaveInvoice(): void
public function postSaveInvoice(): ResponseInterface
{
$batch_save_data = [
'invoice_enable' => $this->request->getPost('invoice_enable') != null,
@@ -938,7 +1012,9 @@ class Config extends Secure_Controller
'work_order_enable' => $this->request->getPost('work_order_enable') != null,
'work_order_format' => $this->request->getPost('work_order_format'),
'last_used_work_order_number' => $this->request->getPost('last_used_work_order_number', FILTER_SANITIZE_NUMBER_INT),
'invoice_type' => $this->request->getPost('invoice_type')
'invoice_type' => Sale_lib::isValidInvoiceType($this->request->getPost('invoice_type'))
? $this->request->getPost('invoice_type')
: 'invoice'
];
$success = $this->appconfig->batch_save($batch_save_data);
@@ -953,20 +1029,42 @@ class Config extends Secure_Controller
}
}
echo json_encode(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
/**
* Removes the company logo from the database. Used in app/Views/configs/info_config.php.
*
* @return void
* @return ResponseInterface
* @throws ReflectionException
* @noinspection PhpUnused
*/
public function postRemoveLogo(): void
public function postRemoveLogo(): ResponseInterface
{
$success = $this->appconfig->save(['company_logo' => '']);
echo json_encode(['success' => $success]);
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

@@ -8,6 +8,7 @@ use App\Models\Customer;
use App\Models\Customer_rewards;
use App\Models\Tax_code;
use CodeIgniter\HTTP\DownloadResponse;
use CodeIgniter\HTTP\ResponseInterface;
use Config\OSPOS;
use Config\Services;
use stdClass;
@@ -40,19 +41,20 @@ class Customers extends Persons
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_customer_manage_table_headers();
echo view('people/manage', $data);
return view('people/manage', $data);
}
/**
* Gets one row for a customer manage table. This is called using AJAX to update one row.
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$person = $this->customer->get_info($row_id);
@@ -72,7 +74,7 @@ class Customers extends Persons
$data_row = get_customer_data_row($person, $stats);
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
@@ -81,7 +83,7 @@ class Customers extends Persons
*
* @return void
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
@@ -111,35 +113,37 @@ class Customers extends Persons
$data_rows[] = get_customer_data_row($person, $stats);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* Gives search suggestions based on what is being searched for
* @return ResponseInterface
*/
public function getSuggest(): void
public function getSuggest(): ResponseInterface
{
$search = $this->request->getGet('term');
$suggestions = $this->customer->get_search_suggestions($search);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* @return void
* @return ResponseInterface
*/
public function suggest_search(): void
public function suggest_search(): ResponseInterface
{
$search = $this->request->getGet('term');
$suggestions = $this->customer->get_search_suggestions($search, 25, false);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* Loads the customer edit form
* @return string
*/
public function getView(int $customer_id = NEW_ENTRY): void
public function getView(int $customer_id = NEW_ENTRY): string
{
// Set default values
if ($customer_id == null) $customer_id = NEW_ENTRY;
@@ -227,13 +231,14 @@ class Customers extends Persons
}
}
echo view("customers/form", $data);
return view("customers/form", $data);
}
/**
* Inserts/updates a customer
* @return ResponseInterface
*/
public function postSave(int $customer_id = NEW_ENTRY): void
public function postSave(int $customer_id = NEW_ENTRY): ResponseInterface
{
$first_name = $this->request->getPost('first_name');
$last_name = $this->request->getPost('last_name');
@@ -288,20 +293,20 @@ class Customers extends Persons
// New customer
if ($customer_id == NEW_ENTRY) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Customers.successful_adding') . ' ' . $first_name . ' ' . $last_name,
'id' => $customer_data['person_id']
]);
} else { // Existing customer
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Customers.successful_updating') . ' ' . $first_name . ' ' . $last_name,
'id' => $customer_id
]);
}
} else { // Failure
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Customers.error_adding_updating') . ' ' . $first_name . ' ' . $last_name,
'id' => NEW_ENTRY
@@ -312,36 +317,37 @@ class Customers extends Persons
/**
* Verifies if an email address already exists. Used in app/Views/customers/form.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postCheckEmail(): void
public function postCheckEmail(): ResponseInterface
{
$email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL));
$person_id = $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT);
$exists = $this->customer->check_email_exists($email, $person_id);
echo !$exists ? 'true' : 'false';
return $this->response->setJSON(!$exists ? 'true' : 'false');
}
/**
* Verifies if an account number already exists. Used in app/Views/customers/form.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postCheckAccountNumber(): void
public function postCheckAccountNumber(): ResponseInterface
{
$exists = $this->customer->check_account_number_exists($this->request->getPost('account_number'), $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT));
echo !$exists ? 'true' : 'false';
return $this->response->setJSON(!$exists ? 'true' : 'false');
}
/**
* This deletes customers from the customers table
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$customers_to_delete = $this->request->getPost('ids');
$customers_info = $this->customer->get_multiple_info($customers_to_delete);
@@ -358,12 +364,12 @@ class Customers extends Persons
}
if ($count == count($customers_to_delete)) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Customers.successful_deleted') . ' ' . $count . ' ' . lang('Customers.one_or_multiple')
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Customers.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Customers.cannot_be_deleted')]);
}
}
@@ -383,24 +389,24 @@ class Customers extends Persons
/**
* Displays the customer CSV import modal. Used in app/Views/people/manage.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function getCsvImport(): void
public function getCsvImport(): string
{
echo view('customers/form_csv_import');
return view('customers/form_csv_import');
}
/**
* Imports a CSV file containing customers. Used in app/Views/customers/form_csv_import.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postImportCsvFile(): void
public function postImportCsvFile(): ResponseInterface
{
if ($_FILES['file_path']['error'] != UPLOAD_ERR_OK) {
echo json_encode(['success' => false, 'message' => lang('Customers.csv_import_failed')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Customers.csv_import_failed')]);
} else {
if (($handle = fopen($_FILES['file_path']['tmp_name'], 'r')) !== false) {
// Skip the first row as it's the table description
@@ -467,12 +473,12 @@ class Customers extends Persons
if (count($failCodes) > 0) {
$message = lang('Customers.csv_import_partially_failed', [count($failCodes), implode(', ', $failCodes)]);
echo json_encode(['success' => false, 'message' => $message]);
return $this->response->setJSON(['success' => false, 'message' => $message]);
} else {
echo json_encode(['success' => true, 'message' => lang('Customers.csv_import_success')]);
return $this->response->setJSON(['success' => true, 'message' => lang('Customers.csv_import_success')]);
}
} else {
echo json_encode(['success' => false, 'message' => lang('Customers.csv_import_nodata_wrongformat')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Customers.csv_import_nodata_wrongformat')]);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Module;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
/**
@@ -25,7 +26,7 @@ class Employees extends Persons
*
* @return void
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
@@ -41,39 +42,47 @@ class Employees extends Persons
$data_rows[] = get_person_data_row($person);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* AJAX called function gives search suggestions based on what is being searched for.
*
* @return void
* @return ResponseInterface
*/
public function getSuggest(): void
public function getSuggest(): ResponseInterface
{
$search = $this->request->getGet('term');
$suggestions = $this->employee->get_search_suggestions($search, 25, true);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* @return void
* @return ResponseInterface
*/
public function suggest_search(): void
public function suggest_search(): ResponseInterface
{
$search = $this->request->getPost('term');
$suggestions = $this->employee->get_search_suggestions($search);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* Loads the employee edit form
* @return string
*/
public function getView(int $employee_id = NEW_ENTRY): void
public function getView(int $employee_id = NEW_ENTRY): string
{
$person_info = $this->employee->get_info($employee_id);
$current_user = $this->employee->get_logged_in_employee_info();
if ($employee_id != NEW_ENTRY && !$this->employee->canModifyEmployee($person_info->person_id, $current_user->person_id)) {
header('Location: ' . base_url('no_access/employees/employees'));
exit();
}
foreach (get_object_vars($person_info) as $property => $value) {
$person_info->$property = $value;
}
@@ -98,14 +107,28 @@ class Employees extends Persons
}
$data['all_subpermissions'] = $permissions;
echo view('employees/form', $data);
return view('employees/form', $data);
}
/**
* Inserts/updates an employee
* @return ResponseInterface
*/
public function postSave(int $employee_id = NEW_ENTRY): void
public function postSave(int $employee_id = NEW_ENTRY): ResponseInterface
{
$current_user = $this->employee->get_logged_in_employee_info();
if ($employee_id != NEW_ENTRY) {
$target_employee = $this->employee->get_info($employee_id);
if (!$this->employee->canModifyEmployee($target_employee->person_id, $current_user->person_id)) {
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.error_updating_admin'),
'id' => NEW_ENTRY
]);
}
}
$first_name = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); // TODO: duplicated code
$last_name = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL));
@@ -130,11 +153,16 @@ class Employees extends Persons
];
$grants_array = [];
$isAdmin = $this->employee->isAdmin($current_user->person_id);
foreach ($this->module->get_all_permissions()->getResult() as $permission) {
$grants = [];
$grant = $this->request->getPost('grant_' . $permission->permission_id) != null ? $this->request->getPost('grant_' . $permission->permission_id, FILTER_SANITIZE_FULL_SPECIAL_CHARS) : '';
if ($grant == $permission->permission_id) {
if (!$isAdmin && !$this->employee->has_grant($permission->permission_id, $current_user->person_id)) {
continue;
}
$grants['permission_id'] = $permission->permission_id;
$grants['menu_group'] = $this->request->getPost('menu_group_' . $permission->permission_id) != null ? $this->request->getPost('menu_group_' . $permission->permission_id, FILTER_SANITIZE_FULL_SPECIAL_CHARS) : '--';
$grants_array[] = $grants;
@@ -163,20 +191,25 @@ class Employees extends Persons
if ($this->employee->save_employee($person_data, $employee_data, $grants_array, $employee_id)) {
// New employee
if ($employee_id == NEW_ENTRY) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Employees.successful_adding') . ' ' . $first_name . ' ' . $last_name,
'id' => $employee_data['person_id']
]);
} else { // Existing employee
echo json_encode([
$logged_in_employee_id = session()->get('person_id');
if ($employee_id == $logged_in_employee_id) {
session()->set('language_code', $employee_data['language_code']);
session()->set('language', $employee_data['language']);
}
return $this->response->setJSON([
'success' => true,
'message' => lang('Employees.successful_updating') . ' ' . $first_name . ' ' . $last_name,
'id' => $employee_id
]);
}
} else { // Failure
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.error_adding_updating') . ' ' . $first_name . ' ' . $last_name,
'id' => NEW_ENTRY
@@ -186,18 +219,28 @@ class Employees extends Persons
/**
* This deletes employees from the employees table
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$employees_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$current_user = $this->employee->get_logged_in_employee_info();
if (!$this->employee->isAdmin($current_user->person_id)) {
foreach ($employees_to_delete as $emp_id) {
if ($this->employee->isAdmin((int)$emp_id)) {
return $this->response->setJSON(['success' => false, 'message' => lang('Employees.error_deleting_admin')]);
}
}
}
if ($this->employee->delete_list($employees_to_delete)) { // TODO: this is passing a string, but delete_list expects an array
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Employees.successful_deleted') . ' ' . count($employees_to_delete) . ' ' . lang('Employees.one_or_multiple')
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Employees.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Employees.cannot_be_deleted')]);
}
}
@@ -205,12 +248,12 @@ class Employees extends Persons
* Checks an employee username against the database. Used in app\Views\employees\form.php
*
* @param $employee_id
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getCheckUsername($employee_id): void
public function getCheckUsername($employee_id): ResponseInterface
{
$exists = $this->employee->username_exists($employee_id, $this->request->getGet('username'));
echo !$exists ? 'true' : 'false';
return $this->response->setJSON(!$exists ? 'true' : 'false');
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Controllers;
use App\Models\Expense;
use App\Models\Expense_category;
use CodeIgniter\HTTP\ResponseInterface;
use Config\OSPOS;
use Config\Services;
@@ -23,7 +24,7 @@ class Expenses extends Secure_Controller
/**
* @return void
*/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_expenses_manage_table_headers();
@@ -37,13 +38,16 @@ class Expenses extends Secure_Controller
'is_deleted' => lang('Expenses.is_deleted')
];
echo view('expenses/manage', $data);
// Restore filters from URL
$data = array_merge($data, restoreTableFilters($this->request));
return view('expenses/manage', $data);
}
/**
* @return void
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
@@ -78,27 +82,34 @@ class Expenses extends Secure_Controller
$data_rows[] = get_expenses_data_last_row($expenses);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows, 'payment_summary' => $payment_summary]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows, 'payment_summary' => $payment_summary]);
}
/**
* @param int $expense_id
* @return void
*/
public function getView(int $expense_id = NEW_ENTRY): void
public function getView(int $expense_id = NEW_ENTRY): string
{
$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) {
@@ -106,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'] = [];
@@ -125,32 +134,46 @@ class Expenses extends Secure_Controller
// Don't allow gift card to be a payment option in a sale transaction edit because it's a complex change
$data['payment_options'] = $this->expense->get_payment_options();
echo view("expenses/form", $data);
return view("expenses/form", $data);
}
/**
* @param int $row_id
* @return void
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$expense_info = $this->expense->get_info($row_id);
$data_row = get_expenses_data_row($expense_info);
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
* @param int $expense_id
* @return void
* @return ResponseInterface
*/
public function postSave(int $expense_id = NEW_ENTRY): void
public function postSave(int $expense_id = NEW_ENTRY): ResponseInterface
{
$config = config(OSPOS::class)->settings;
$newdate = $this->request->getPost('date', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$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),
@@ -160,33 +183,33 @@ 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
];
if ($this->expense->save_value($expense_data, $expense_id)) {
// New Expense
if ($expense_id == NEW_ENTRY) {
echo json_encode(['success' => true, 'message' => lang('Expenses.successful_adding'), 'id' => $expense_data['expense_id']]);
return $this->response->setJSON(['success' => true, 'message' => lang('Expenses.successful_adding'), 'id' => $expense_data['expense_id']]);
} else { // Existing Expense
echo json_encode(['success' => true, 'message' => lang('Expenses.successful_updating'), 'id' => $expense_id]);
return $this->response->setJSON(['success' => true, 'message' => lang('Expenses.successful_updating'), 'id' => $expense_id]);
}
} else { // Failure
echo json_encode(['success' => false, 'message' => lang('Expenses.error_adding_updating'), 'id' => NEW_ENTRY]);
return $this->response->setJSON(['success' => false, 'message' => lang('Expenses.error_adding_updating'), 'id' => NEW_ENTRY]);
}
}
/**
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$expenses_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if ($this->expense->delete_list($expenses_to_delete)) {
echo json_encode(['success' => true, 'message' => lang('Expenses.successful_deleted') . ' ' . count($expenses_to_delete) . ' ' . lang('Expenses.one_or_multiple'), 'ids' => $expenses_to_delete]);
return $this->response->setJSON(['success' => true, 'message' => lang('Expenses.successful_deleted') . ' ' . count($expenses_to_delete) . ' ' . lang('Expenses.one_or_multiple'), 'ids' => $expenses_to_delete]);
} else {
echo json_encode(['success' => false, 'message' => lang('Expenses.cannot_be_deleted'), 'ids' => $expenses_to_delete]);
return $this->response->setJSON(['success' => false, 'message' => lang('Expenses.cannot_be_deleted'), 'ids' => $expenses_to_delete]);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Expense_category;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class Expenses_categories extends Secure_Controller // TODO: Is this class ever used?
@@ -19,17 +20,17 @@ class Expenses_categories extends Secure_Controller // TODO: Is this class ev
/**
* @return void
*/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_expense_category_manage_table_headers();
echo view('expenses_categories/manage', $data);
return view('expenses_categories/manage', $data);
}
/**
* Returns expense_category_manage table data rows. This will be called with AJAX.
**/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
@@ -45,36 +46,36 @@ class Expenses_categories extends Secure_Controller // TODO: Is this class ev
$data_rows[] = get_expense_category_data_row($expense_category);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* @param int $row_id
* @return void
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$data_row = get_expense_category_data_row($this->expense_category->get_info($row_id));
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
* @param int $expense_category_id
* @return void
*/
public function getView(int $expense_category_id = NEW_ENTRY): void
public function getView(int $expense_category_id = NEW_ENTRY): string
{
$data['category_info'] = $this->expense_category->get_info($expense_category_id);
echo view("expenses_categories/form", $data);
return view("expenses_categories/form", $data);
}
/**
* @param int $expense_category_id
* @return void
*/
public function postSave(int $expense_category_id = NEW_ENTRY): void
public function postSave(int $expense_category_id = NEW_ENTRY): ResponseInterface
{
$expense_category_data = [
'category_name' => $this->request->getPost('category_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
@@ -84,20 +85,20 @@ class Expenses_categories extends Secure_Controller // TODO: Is this class ev
if ($this->expense_category->save_value($expense_category_data, $expense_category_id)) {
// New expense_category
if ($expense_category_id == NEW_ENTRY) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Expenses_categories.successful_adding'),
'id' => $expense_category_data['expense_category_id']
]);
} else { // Existing Expense Category
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Expenses_categories.successful_updating'),
'id' => $expense_category_id
]);
}
} else { // Failure
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Expenses_categories.error_adding_updating') . ' ' . $expense_category_data['category_name'],
'id' => NEW_ENTRY
@@ -108,17 +109,17 @@ class Expenses_categories extends Secure_Controller // TODO: Is this class ev
/**
* @return void
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$expense_category_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if ($this->expense_category->delete_list($expense_category_to_delete)) { // TODO: Convert to ternary notation.
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Expenses_categories.successful_deleted') . ' ' . count($expense_category_to_delete) . ' ' . lang('Expenses_categories.one_or_multiple')
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Expenses_categories.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Expenses_categories.cannot_be_deleted')]);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Giftcard;
use CodeIgniter\HTTP\ResponseInterface;
use Config\OSPOS;
use Config\Services;
@@ -18,19 +19,19 @@ class Giftcards extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_giftcards_manage_table_headers();
echo view('giftcards/manage', $data);
return view('giftcards/manage', $data);
}
/**
* Returns Giftcards table data rows. This will be called with AJAX.
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
@@ -46,50 +47,50 @@ class Giftcards extends Secure_Controller
$data_rows[] = get_giftcard_data_row($giftcard);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* Gets search suggestions for giftcards. Used in app\Views\sales\register.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getSuggest(): void
public function getSuggest(): ResponseInterface
{
$search = $this->request->getGet('term');
$suggestions = $this->giftcard->get_search_suggestions($search, true);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* @return void
* @return ResponseInterface
*/
public function suggest_search(): void
public function suggest_search(): ResponseInterface
{
$search = $this->request->getPost('term');
$suggestions = $this->giftcard->get_search_suggestions($search);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* @param int $row_id
* @return void
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$data_row = get_giftcard_data_row($this->giftcard->get_info($row_id));
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
* @param int $giftcard_id
* @return void
* @return string
*/
public function getView(int $giftcard_id = NEW_ENTRY): void
public function getView(int $giftcard_id = NEW_ENTRY): string
{
$config = config(OSPOS::class)->settings;
$giftcard_info = $this->giftcard->get_info($giftcard_id);
@@ -106,14 +107,14 @@ class Giftcards extends Secure_Controller
$data['giftcard_id'] = $giftcard_id;
$data['giftcard_value'] = $giftcard_info->value;
echo view("giftcards/form", $data);
return view("giftcards/form", $data);
}
/**
* @param int $giftcard_id
* @return void
* @return ResponseInterface
*/
public function postSave(int $giftcard_id = NEW_ENTRY): void
public function postSave(int $giftcard_id = NEW_ENTRY): ResponseInterface
{
$giftcard_number = $this->request->getPost('giftcard_number', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
@@ -125,26 +126,26 @@ class Giftcards extends Secure_Controller
'record_time' => date('Y-m-d H:i:s'),
'giftcard_number' => $giftcard_number,
'value' => parse_decimals($this->request->getPost('giftcard_amount')),
'person_id' => $this->request->getPost('person_id') == '' ? null : $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT)
'person_id' => empty($this->request->getPost('person_id')) ? null : $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT)
];
if ($this->giftcard->save_value($giftcard_data, $giftcard_id)) {
// New giftcard
if ($giftcard_id == NEW_ENTRY) { // TODO: Constant needed
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Giftcards.successful_adding') . ' ' . $giftcard_data['giftcard_number'],
'id' => $giftcard_data['giftcard_id']
]);
} else { // Existing giftcard
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Giftcards.successful_updating') . ' ' . $giftcard_data['giftcard_number'],
'id' => $giftcard_id
]);
}
} else { // Failure
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Giftcards.error_adding_updating') . ' ' . $giftcard_data['giftcard_number'],
'id' => NEW_ENTRY
@@ -158,27 +159,30 @@ class Giftcards extends Secure_Controller
* @return void
* @noinspection PhpUnused
*/
public function postCheckNumberGiftcard(): void
public function postCheckNumberGiftcard(): ResponseInterface
{
$giftcard_amount = parse_decimals($this->request->getPost('giftcard_amount'));
$parsed_value = filter_var($giftcard_amount, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
echo json_encode(['success' => $parsed_value !== false && $parsed_value > 0 && $giftcard_amount !== false, 'giftcard_amount' => to_currency_no_money($parsed_value)]);
$existing_id = $this->request->getPost('giftcard_id', FILTER_SANITIZE_NUMBER_INT);
$giftcard_number = $this->request->getPost('giftcard_number', FILTER_SANITIZE_NUMBER_INT);
$giftcard_id = $this->giftcard->get_giftcard_id($giftcard_number);
$success = ($giftcard_id == (int) $existing_id || !$giftcard_id );
return $this->response->setJSON($success ? 'true' : 'false');
}
/**
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$giftcards_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if ($this->giftcard->delete_list($giftcards_to_delete)) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Giftcards.successful_deleted') . ' ' . count($giftcards_to_delete) . ' ' . lang('Giftcards.one_or_multiple')
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Giftcards.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Giftcards.cannot_be_deleted')]);
}
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Controllers;
use App\Libraries\MY_Migration;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\ResponseInterface;
class Home extends Secure_Controller
{
@@ -12,12 +14,12 @@ class Home extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$logged_in = $this->employee->is_logged_in();
echo view('home/home');
return view('home/home');
}
/**
@@ -33,59 +35,94 @@ class Home extends Secure_Controller
}
/**
* Load "change employee password" form
* Load the "change employee password" form
*
* @noinspection PhpUnused
* @param int $employeeId
* @return ResponseInterface|string
*/
public function getChangePassword(int $employee_id = -1): void // TODO: Replace -1 with a constant
public function getChangePassword(int $employeeId = NEW_ENTRY): ResponseInterface|string
{
$person_info = $this->employee->get_info($employee_id);
$loggedInEmployee = $this->employee->get_logged_in_employee_info();
$currentPersonId = (int) $loggedInEmployee->person_id;
$employeeId = $employeeId === NEW_ENTRY ? $currentPersonId : $employeeId;
if (!$this->employee->isAdmin($currentPersonId) && $employeeId !== $currentPersonId) {
return $this->response->setStatusCode(403)->setBody(lang('Employees.unauthorized_modify'));
}
$person_info = $this->employee->get_info($employeeId);
foreach (get_object_vars($person_info) as $property => $value) {
$person_info->$property = $value;
}
$data['person_info'] = $person_info;
echo view('home/form_change_password', $data);
return view('home/form_change_password', $data);
}
/**
* Change employee password
*
* @return ResponseInterface
*/
public function postSave(int $employee_id = -1): void // TODO: Replace -1 with a constant
public function postSave(int $employeeId = NEW_ENTRY): ResponseInterface
{
if (!empty($this->request->getPost('current_password')) && $employee_id != -1) {
$currentUser = $this->employee->get_logged_in_employee_info();
$currentPersonId = (int) $currentUser->person_id;
$employeeId = $employeeId === NEW_ENTRY ? $currentPersonId : $employeeId;
if (!$this->employee->isAdmin($currentPersonId) && $employeeId !== $currentPersonId) {
return $this->response->setStatusCode(403)->setJSON([
'success' => false,
'message' => lang('Employees.unauthorized_modify')
]);
}
if (!empty($this->request->getPost('current_password')) && $employeeId != NEW_ENTRY) {
if ($this->employee->check_password($this->request->getPost('username', FILTER_SANITIZE_FULL_SPECIAL_CHARS), $this->request->getPost('current_password'))) {
// Validate password length BEFORE hashing
$new_password = $this->request->getPost('password');
if (strlen($new_password) < 8) {
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.password_minlength'),
'id' => NEW_ENTRY
]);
}
$employee_data = [
'username' => $this->request->getPost('username', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
'password' => password_hash($this->request->getPost('password'), PASSWORD_DEFAULT),
'password' => password_hash($new_password, PASSWORD_DEFAULT),
'hash_version' => 2
];
if ($this->employee->change_password($employee_data, $employee_id)) {
echo json_encode([
if ($this->employee->change_password($employee_data, $employeeId)) {
return $this->response->setJSON([
'success' => true,
'message' => lang('Employees.successful_change_password'),
'id' => $employee_id
'id' => $employeeId
]);
} else { // Failure // TODO: Replace -1 with constant
echo json_encode([
} else {
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.unsuccessful_change_password'),
'id' => -1
'id' => NEW_ENTRY
]);
}
} else { // TODO: Replace -1 with constant
echo json_encode([
} else {
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.current_password_invalid'),
'id' => -1
'id' => NEW_ENTRY
]);
}
} else { // TODO: Replace -1 with constant
echo json_encode([
} else {
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.current_password_invalid'),
'id' => -1
'id' => NEW_ENTRY
]);
}
}

View File

@@ -7,6 +7,7 @@ use App\Libraries\Barcode_lib;
use App\Models\Item;
use App\Models\Item_kit;
use App\Models\Item_kit_items;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class Item_kits extends Secure_Controller
@@ -59,19 +60,19 @@ class Item_kits extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_item_kits_manage_table_headers();
echo view('item_kits/manage', $data);
return view('item_kits/manage', $data);
}
/**
* Returns Item_kit table data rows. This will be called with AJAX.
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search') ?? '';
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
@@ -89,37 +90,37 @@ class Item_kits extends Secure_Controller
$data_rows[] = get_item_kit_data_row($item_kit);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* @return void
* @return ResponseInterface
*/
public function suggest_search(): void
public function suggest_search(): ResponseInterface
{
$search = $this->request->getPost('term');
$suggestions = $this->item_kit->get_search_suggestions($search);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* @param int $row_id
* @return void
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
// Calculate the total cost and retail price of the Kit, so it can be added to the table refresh
$item_kit = $this->_add_totals_to_item_kit($this->item_kit->get_info($row_id));
echo json_encode(get_item_kit_data_row($item_kit));
return $this->response->setJSON(get_item_kit_data_row($item_kit));
}
/**
* @param int $item_kit_id
* @return void
* @return string
*/
public function getView(int $item_kit_id = NEW_ENTRY): void
public function getView(int $item_kit_id = NEW_ENTRY): string
{
$info = $this->item_kit->get_info($item_kit_id);
@@ -153,19 +154,19 @@ class Item_kits extends Secure_Controller
$data['selected_kit_item_id'] = $info->kit_item_id;
$data['selected_kit_item'] = ($item_kit_id > 0 && isset($info->kit_item_id)) ? $info->item_name : '';
echo view("item_kits/form", $data);
return view("item_kits/form", $data);
}
/**
* @param int $item_kit_id
* @return void
* @return ResponseInterface
*/
public function postSave(int $item_kit_id = NEW_ENTRY): void
public function postSave(int $item_kit_id = NEW_ENTRY): ResponseInterface
{
$item_kit_data = [
'name' => $this->request->getPost('name'),
'item_kit_number' => $this->request->getPost('item_kit_number'),
'item_id' => $this->request->getPost('kit_item_id') ? null : intval($this->request->getPost('kit_item_id')),
'item_id' => $this->request->getPost('kit_item_id'),
'kit_discount' => parse_decimals($this->request->getPost('kit_discount')),
'kit_discount_type' => $this->request->getPost('kit_discount_type') === null ? PERCENT : intval($this->request->getPost('kit_discount_type')),
'price_option' => $this->request->getPost('price_option') === null ? PRICE_ALL : intval($this->request->getPost('price_option')),
@@ -201,20 +202,20 @@ class Item_kits extends Secure_Controller
}
if ($new_item) {
echo json_encode([
return $this->response->setJSON([
'success' => $success,
'message' => lang('Item_kits.successful_adding') . ' ' . $item_kit_data['name'],
'id' => $item_kit_id
]);
} else {
echo json_encode([
return $this->response->setJSON([
'success' => $success,
'message' => lang('Item_kits.successful_updating') . ' ' . $item_kit_data['name'],
'id' => $item_kit_id
]);
}
} else { // Failure
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Item_kits.error_adding_updating') . ' ' . $item_kit_data['name'],
'id' => NEW_ENTRY
@@ -223,42 +224,42 @@ class Item_kits extends Secure_Controller
}
/**
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$item_kits_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
if ($this->item_kit->delete_list($item_kits_to_delete)) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Item_kits.successful_deleted') . ' ' . count($item_kits_to_delete) . ' ' . lang('Item_kits.one_or_multiple')
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Item_kits.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Item_kits.cannot_be_deleted')]);
}
}
/**
* Checks the validity of the item kit number. Used in app/Views/item_kits/form.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postCheckItemNumber(): void
public function postCheckItemNumber(): ResponseInterface
{
$exists = $this->item_kit->item_number_exists($this->request->getPost('item_kit_number', FILTER_SANITIZE_FULL_SPECIAL_CHARS), $this->request->getPost('item_kit_id', FILTER_SANITIZE_NUMBER_INT));
echo !$exists ? 'true' : 'false';
return $this->response->setJSON(!$exists ? 'true' : 'false');
}
/**
* AJAX called function that generates barcodes for selected item_kits.
*
* @param string $item_kit_ids Colon separated list of item_kit_id values to generate barcodes for.
* @return void
* @return string
* @noinspection PhpUnused
*/
public function getGenerateBarcodes(string $item_kit_ids): void
public function getGenerateBarcodes(string $item_kit_ids): string
{
$barcode_lib = new Barcode_lib();
$result = [];
@@ -289,6 +290,6 @@ class Item_kits extends Secure_Controller
$data['barcode_config'] = $barcode_config;
// Display barcodes
echo view("barcodes/barcode_sheet", $data);
return view("barcodes/barcode_sheet", $data);
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ namespace App\Controllers;
use App\Libraries\MY_Migration;
use App\Models\Employee;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Model;
use Config\OSPOS;
use Config\Services;
@@ -36,6 +37,7 @@ class Login extends BaseController
$data = [
'has_errors' => false,
'is_new_install' => !(MY_Migration::get_current_version()),
'is_latest' => $migration->is_latest(),
'latest_version' => $migration->get_latest_migration(),
'gcaptcha_enabled' => $gcaptcha_enabled,
@@ -47,6 +49,13 @@ class Login extends BaseController
return view('login', $data);
}
if (!$data['is_latest'] || $data['is_new_install']) {
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return redirect()->to('login');
}
$rules = ['username' => 'required|login_check[data]'];
$messages = [
'username' => [
@@ -60,15 +69,32 @@ class Login extends BaseController
return view('login', $data);
}
if (!$data['is_latest']) {
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return redirect()->to('login');
}
}
return redirect()->to('home');
}
public function migrate(): ResponseInterface
{
try {
$migration = new MY_Migration(config('Migrations'));
$migration->migrate_to_ci4();
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return $this->response->setJSON([
'success' => true,
'message' => 'Migration completed successfully'
]);
} catch (\Exception $e) {
log_message('error', 'Migration failed: ' . $e->getMessage());
return $this->response->setJSON([
'success' => false,
'message' => 'Migration failed: ' . $e->getMessage()
])->setStatusCode(500);
}
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Controllers;
use App\Libraries\Sms_lib;
use App\Models\Person;
use CodeIgniter\HTTP\ResponseInterface;
class Messages extends Secure_Controller
{
@@ -18,18 +19,18 @@ class Messages extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
echo view('messages/sms');
return view('messages/sms');
}
/**
* @param int $person_id
* @return void
* @return string
*/
public function getView(int $person_id = NEW_ENTRY): void
public function getView(int $person_id = NEW_ENTRY): string
{
$person = model(Person::class);
$info = $person->get_info($person_id);
@@ -39,13 +40,13 @@ class Messages extends Secure_Controller
}
$data['person_info'] = $info;
echo view('messages/form_sms', $data);
return view('messages/form_sms', $data);
}
/**
* @return void
* @return ResponseInterface
*/
public function send(): void
public function send(): ResponseInterface
{
$phone = $this->request->getPost('phone', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$message = $this->request->getPost('message', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
@@ -53,9 +54,9 @@ class Messages extends Secure_Controller
$response = $this->sms_lib->sendSMS($phone, $message);
if ($response) {
echo json_encode(['success' => true, 'message' => lang('Messages.successfully_sent') . ' ' . esc($phone)]);
return $this->response->setJSON(['success' => true, 'message' => lang('Messages.successfully_sent') . ' ' . esc($phone)]);
} else {
echo json_encode(['success' => false, 'message' => lang('Messages.unsuccessfully_sent') . ' ' . esc($phone)]);
return $this->response->setJSON(['success' => false, 'message' => lang('Messages.unsuccessfully_sent') . ' ' . esc($phone)]);
}
}
@@ -63,10 +64,10 @@ class Messages extends Secure_Controller
* Sends an SMS message to a user. Used in app/Views/messages/form_sms.php.
*
* @param int $person_id
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function send_form(int $person_id = NEW_ENTRY): void
public function send_form(int $person_id = NEW_ENTRY): ResponseInterface
{
$phone = $this->request->getPost('phone', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$message = $this->request->getPost('message', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
@@ -74,13 +75,13 @@ class Messages extends Secure_Controller
$response = $this->sms_lib->sendSMS($phone, $message);
if ($response) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Messages.successfully_sent') . ' ' . esc($phone),
'person_id' => $person_id
]);
} else {
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Messages.unsuccessfully_sent') . ' ' . esc($phone),
'person_id' => NEW_ENTRY

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Module;
use CodeIgniter\HTTP\ResponseInterface;
/**
* Part of the grants mechanism to restrict access to modules that the user doesn't have permission for.
@@ -22,13 +23,13 @@ class No_access extends BaseController
/**
* @param string $module_id
* @param string $permission_id
* @return void
* @return string
*/
public function getIndex(string $module_id = '', string $permission_id = ''): void
public function getIndex(string $module_id = '', string $permission_id = ''): string
{
$data['module_name'] = $this->module->get_module_name($module_id);
$data['permission_id'] = $permission_id;
echo view('no_access', $data);
return view('no_access', $data);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Employee;
use CodeIgniter\HTTP\ResponseInterface;
/**
* @property Employee employee
@@ -17,11 +18,11 @@ class Office extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
echo view('home/office');
return view('home/office');
}
/**

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Person;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
use function Tamtamchik\NameCase\str_name_case;
@@ -21,34 +22,36 @@ abstract class Persons extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_people_manage_table_headers();
echo view('people/manage', $data);
return view('people/manage', $data);
}
/**
* Gives search suggestions based on what is being searched for
* @return ResponseInterface
*/
public function getSuggest(): void
public function getSuggest(): ResponseInterface
{
$search = $this->request->getGet('term');
$suggestions = $this->person->get_search_suggestions($search);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* Gets one row for a person manage table. This is called using AJAX to update one row.
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$data_row = get_person_data_row($this->person->get_info($row_id));
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**

View File

@@ -11,6 +11,7 @@ use App\Models\Item_kit;
use App\Models\Receiving;
use App\Models\Stock_location;
use App\Models\Supplier;
use CodeIgniter\HTTP\ResponseInterface;
use Config\OSPOS;
use Config\Services;
use ReflectionException;
@@ -46,66 +47,66 @@ class Receivings extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$this->_reload();
return $this->_reload();
}
/**
* Returns search suggestions for an item. Used in app/Views/sales/register.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getItemSearch(): void
public function getItemSearch(): ResponseInterface
{
$search = $this->request->getGet('term');
$suggestions = $this->item->get_search_suggestions($search, ['search_custom' => false, 'is_deleted' => false], true);
$suggestions = array_merge($suggestions, $this->item_kit->get_search_suggestions($search));
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* Gets search suggestions for a stock item. Used in app/Views/receivings/receiving.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getStockItemSearch(): void
public function getStockItemSearch(): ResponseInterface
{
$search = $this->request->getGet('term');
$suggestions = $this->item->get_stock_search_suggestions($search, ['search_custom' => false, 'is_deleted' => false], true);
$suggestions = array_merge($suggestions, $this->item_kit->get_search_suggestions($search));
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* Set supplier if it exists in the database. Used in app/Views/receivings/receiving.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function postSelectSupplier(): void
public function postSelectSupplier(): string
{
$supplier_id = $this->request->getPost('supplier', FILTER_SANITIZE_NUMBER_INT);
if ($this->supplier->exists($supplier_id)) {
$this->receiving_lib->set_supplier($supplier_id);
}
$this->_reload(); // TODO: Hungarian notation
return $this->_reload(); // TODO: Hungarian notation
}
/**
* Change receiving mode for current receiving. Used in app/Views/receivings/receiving.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function postChangeMode(): void
public function postChangeMode(): string
{
$stock_destination = $this->request->getPost('stock_destination', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$stock_source = $this->request->getPost('stock_source', FILTER_SANITIZE_NUMBER_INT);
@@ -121,49 +122,49 @@ class Receivings extends Secure_Controller
$this->receiving_lib->set_stock_destination($stock_destination);
}
$this->_reload(); // TODO: Hungarian notation
return $this->_reload(); // TODO: Hungarian notation
}
/**
* Sets receiving comment. Used in app/Views/receivings/receiving.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSetComment(): void
public function postSetComment(): ResponseInterface
{
$this->receiving_lib->set_comment($this->request->getPost('comment', FILTER_SANITIZE_FULL_SPECIAL_CHARS));
return $this->response->setJSON(['success' => true]);
}
/**
* Sets the print after sale flag for the receiving. Used in app/Views/receivings/receiving.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSetPrintAfterSale(): void
public function postSetPrintAfterSale(): ResponseInterface
{
$this->receiving_lib->set_print_after_sale($this->request->getPost('recv_print_after_sale') != null);
return $this->response->setJSON(['success' => true]);
}
/**
* Sets the reference number for the receiving. Used in app/Views/receivings/receiving.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postSetReference(): void
public function postSetReference(): ResponseInterface
{
$this->receiving_lib->set_reference($this->request->getPost('recv_reference', FILTER_SANITIZE_FULL_SPECIAL_CHARS));
return $this->response->setJSON(['success' => true]);
}
/**
* Add an item to the receiving. Used in app/Views/receivings/receiving.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function postAdd(): void
public function postAdd(): string
{
$data = [];
@@ -183,17 +184,17 @@ class Receivings extends Secure_Controller
$data['error'] = lang('Receivings.unable_to_add_item');
}
$this->_reload($data); // TODO: Hungarian notation
return $this->_reload($data); // TODO: Hungarian notation
}
/**
* Edit line item in current receiving. Used in app/Views/receivings/receiving.php
*
* @param $item_id
* @return void
* @param int|string|null $item_id
* @return string
* @noinspection PhpUnused
*/
public function postEditItem($item_id): void
public function postEditItem(int|string|null $item_id): string
{
$data = [];
@@ -222,17 +223,16 @@ class Receivings extends Secure_Controller
$data['error'] = lang('Receivings.error_editing_item');
}
$this->_reload($data); // TODO: Hungarian notation
return $this->_reload($data); // TODO: Hungarian notation
}
/**
* Edit a receiving. Used in app/Controllers/Receivings.php
*
* @param $receiving_id
* @return void
* @return string
* @noinspection PhpUnused
*/
public function getEdit($receiving_id): void
public function getEdit($receiving_id): string
{
$data = [];
@@ -241,73 +241,88 @@ 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;
echo view('receivings/form', $data);
return view('receivings/form', $data);
}
/**
* Deletes an item from the current receiving. Used in app/Views/receivings/receiving.php
*
* @param $item_number
* @return void
* @return string
* @noinspection PhpUnused
*/
public function getDeleteItem($item_number): void
public function getDeleteItem($item_number): string
{
$this->receiving_lib->delete_item($item_number);
$this->_reload(); // TODO: Hungarian notation
return $this->_reload(); // TODO: Hungarian notation
}
/**
* @param int $receiving_id
* @param bool $update_inventory
* @return ResponseInterface
* @throws ReflectionException
*/
public function postDelete(int $receiving_id = -1, bool $update_inventory = true): void
public function postDelete(int $receiving_id = -1, bool $update_inventory = true): ResponseInterface
{
$employee_id = $this->employee->get_logged_in_employee_info()->person_id;
$receiving_ids = $receiving_id == -1 ? $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT) : [$receiving_id]; // TODO: Replace -1 with constant
if ($this->receiving->delete_list($receiving_ids, $employee_id, $update_inventory)) { // TODO: Likely need to surround this block of code in a try-catch to catch the ReflectionException
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Receivings.successfully_deleted') . ' ' . count($receiving_ids) . ' ' . lang('Receivings.one_or_multiple'),
'ids' => $receiving_ids
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Receivings.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Receivings.cannot_be_deleted')]);
}
}
/**
* Removes a supplier from a receiving. Used in app/Views/receivings/receiving.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function getRemoveSupplier(): void
public function getRemoveSupplier(): string
{
$this->receiving_lib->clear_reference();
$this->receiving_lib->remove_supplier();
$this->_reload(); // TODO: Hungarian notation
return $this->_reload(); // TODO: Hungarian notation
}
/**
* Complete and finalize receiving. Used in app/Views/receivings/receiving.php
*
* @return string
* @throws ReflectionException
* @noinspection PhpUnused
*/
public function postComplete(): void
public function postComplete(): string
{
$data = [];
@@ -356,18 +371,21 @@ class Receivings extends Secure_Controller
$data['print_after_sale'] = $this->receiving_lib->is_print_after_sale();
echo view("receivings/receipt", $data);
$view = view("receivings/receipt", $data);
$this->receiving_lib->clear_all();
return $view;
}
/**
* Complete a receiving requisition. Used in app/Views/receivings/receiving.php.
*
* @return string
* @throws ReflectionException
* @noinspection PhpUnused
*/
public function postRequisitionComplete(): void
public function postRequisitionComplete(): string
{
if ($this->receiving_lib->get_stock_source() != $this->receiving_lib->get_stock_destination()) {
foreach ($this->receiving_lib->get_cart() as $item) {
@@ -376,11 +394,11 @@ class Receivings extends Secure_Controller
$this->receiving_lib->add_item($item['item_id'], -$item['quantity'], $this->receiving_lib->get_stock_source(), $item['discount_type']);
}
$this->postComplete();
return $this->postComplete();
} else {
$data['error'] = lang('Receivings.error_requisition');
$this->_reload($data); // TODO: Hungarian notation
return $this->_reload($data); // TODO: Hungarian notation
}
}
@@ -388,10 +406,10 @@ class Receivings extends Secure_Controller
* Gets the receipt for a receiving. Used in app/Views/receivings/form.php
*
* @param $receiving_id
* @return void
* @return string
* @noinspection PhpUnused
*/
public function getReceipt($receiving_id): void
public function getReceipt($receiving_id): string
{
$receiving_info = $this->receiving->get_info($receiving_id)->getRowArray();
$this->receiving_lib->copy_entire_receiving($receiving_id);
@@ -424,16 +442,18 @@ class Receivings extends Secure_Controller
$data['print_after_sale'] = false;
echo view("receivings/receipt", $data);
$view = view("receivings/receipt", $data);
$this->receiving_lib->clear_all();
return $view;
}
/**
* @param array $data
* @return void
* @return string
*/
private function _reload(array $data = []): void // TODO: Hungarian notation
private function _reload(array $data = []): string // TODO: Hungarian notation
{
$data['cart'] = $this->receiving_lib->get_cart();
$data['modes'] = ['receive' => lang('Receivings.receiving'), 'return' => lang('Receivings.return')];
@@ -470,36 +490,47 @@ class Receivings extends Secure_Controller
$data['print_after_sale'] = $this->receiving_lib->is_print_after_sale();
echo view("receivings/receiving", $data);
return view("receivings/receiving", $data);
}
/**
* @return ResponseInterface
* @throws ReflectionException
*/
public function postSave(int $receiving_id = -1): void // TODO: Replace -1 with a constant
public function postSave(int $receiving_id = -1): ResponseInterface // TODO: Replace -1 with a constant
{
$newdate = $this->request->getPost('date', FILTER_SANITIZE_FULL_SPECIAL_CHARS); // TODO: newdate does not follow naming conventions
$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
];
$this->inventory->update('RECV ' . $receiving_id, ['trans_date' => $receiving_time]);
if ($this->receiving->update($receiving_id, $receiving_data)) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Receivings.successfully_updated'),
'id' => $receiving_id
]);
} else {
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Receivings.unsuccessfully_updated'),
'id' => $receiving_id
@@ -510,13 +541,13 @@ class Receivings extends Secure_Controller
/**
* Cancel an in-process receiving. Used in app/Views/receivings/receiving.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function postCancelReceiving(): void
public function postCancelReceiving(): string
{
$this->receiving_lib->clear_all();
$this->_reload(); // TODO: Hungarian Notation
return $this->_reload(); // TODO: Hungarian Notation
}
}

View File

@@ -25,6 +25,7 @@ use App\Models\Reports\Summary_sales;
use App\Models\Reports\Summary_sales_taxes;
use App\Models\Reports\Summary_suppliers;
use App\Models\Reports\Summary_taxes;
use CodeIgniter\HTTP\ResponseInterface;
use Config\OSPOS;
use Config\Services;
@@ -84,7 +85,8 @@ class Reports extends Secure_Controller
// Check access to report submodule
if (!$this->employee->has_grant('reports_' . $submodule_id, $this->employee->get_logged_in_employee_info()->person_id)) {
redirect('no_access/reports/reports_' . $submodule_id);
header('Location: ' . base_url('no_access/reports/reports_' . $submodule_id));
exit();
}
}
@@ -101,8 +103,9 @@ class Reports extends Secure_Controller
/**
* Initial Report listing screen
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$person_id = $this->session->get('person_id');
$grants = $this->employee->get_employee_grants($this->session->get('person_id'));
@@ -114,7 +117,7 @@ class Reports extends Secure_Controller
'permission_ids' => $permissions_ids,
];
echo view('reports/listing', $data);
return view('reports/listing', $data);
}
/**
@@ -123,9 +126,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function summary_sales(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void // TODO: Perhaps these need to be passed as an array? Too many parameters in the signature.
public function summary_sales(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string // TODO: Perhaps these need to be passed as an array? Too many parameters in the signature.
{ // TODO: Duplicated code
$this->clearCache();
@@ -161,7 +164,7 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
@@ -170,9 +173,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function summary_categories(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function summary_categories(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{ // TODO: Duplicated code
$this->clearCache();
@@ -207,7 +210,7 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
@@ -215,9 +218,9 @@ class Reports extends Secure_Controller
* @param string $start_date
* @param string $end_date
* @param string $sale_type
* @return void
* @return string
*/
public function summary_expenses_categories(string $start_date, string $end_date, string $sale_type): void
public function summary_expenses_categories(string $start_date, string $end_date, string $sale_type): string
{
$this->clearCache();
@@ -244,7 +247,7 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
@@ -253,9 +256,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function summary_customers(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function summary_customers(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{
$this->clearCache();
@@ -292,7 +295,7 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
@@ -301,9 +304,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function summary_suppliers(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function summary_suppliers(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -338,7 +341,7 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
@@ -347,9 +350,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function summary_items(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function summary_items(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{
$this->clearCache();
@@ -388,7 +391,7 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
@@ -397,9 +400,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function summary_employees(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function summary_employees(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{
$this->clearCache();
@@ -436,7 +439,7 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
@@ -445,9 +448,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function summary_taxes(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function summary_taxes(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{ // TODO: Duplicate Code
$this->clearCache();
@@ -482,13 +485,14 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
* Summary Sales Taxes report
* @return string
*/
public function summary_sales_taxes(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function summary_sales_taxes(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{ // TODO: Duplicated code
$this->clearCache();
@@ -521,16 +525,16 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
* Summary Discounts report input. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function summary_discounts_input(): void
public function summary_discounts_input(): string
{
$this->clearCache();
@@ -541,13 +545,14 @@ class Reports extends Secure_Controller
$data['discount_type_options'] = ['0' => lang('Reports.discount_percent'), '1' => lang('Reports.discount_fixed')];
$data['sale_type_options'] = $this->get_sale_type_options();
echo view('reports/date_input', $data);
return view('reports/date_input', $data);
}
/**
* Summary Discounts report
* @return string
**/
public function summary_discounts(string $start_date, string $end_date, string $sale_type, string $location_id = 'all', int $discount_type = 0): void
public function summary_discounts(string $start_date, string $end_date, string $sale_type, string $location_id = 'all', int $discount_type = 0): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -579,13 +584,14 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
* Summary Payments report
* @return string
*/
public function summary_payments(string $start_date, string $end_date): void
public function summary_payments(string $start_date, string $end_date): string
{
$this->clearCache();
@@ -637,16 +643,16 @@ class Reports extends Secure_Controller
'summary_data' => $summary
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
* Input for reports that require only a date range. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function date_input(): void
public function date_input(): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -656,30 +662,30 @@ class Reports extends Secure_Controller
$data['mode'] = 'sale';
$data['sale_type_options'] = $this->get_sale_type_options();
echo view('reports/date_input', $data);
return view('reports/date_input', $data);
}
/**
* Input for reports that require only a date range. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function date_input_only(): void
public function date_input_only(): string
{
$this->clearCache();
$data = [];
echo view('reports/date_input', $data);
return view('reports/date_input', $data);
}
/**
* Input for reports that require only a date range. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function date_input_sales(): void
public function date_input_sales(): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -689,23 +695,23 @@ class Reports extends Secure_Controller
$data['mode'] = 'sale';
$data['sale_type_options'] = $this->get_sale_type_options();
echo view('reports/date_input', $data);
return view('reports/date_input', $data);
}
/**
* Receivings date input. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function date_input_recv(): void
public function date_input_recv(): string
{
$stock_locations = $data = $this->stock_location->get_allowed_locations('receivings');
$stock_locations['all'] = lang('Reports.all');
$data['stock_locations'] = array_reverse($stock_locations, true);
$data['mode'] = 'receiving';
echo view('reports/date_input', $data);
return view('reports/date_input', $data);
}
/**
@@ -714,10 +720,10 @@ class Reports extends Secure_Controller
* @param string $start_date
* @param string $end_date
* @param string $sale_type
* @return void
* @return string
* @noinspection PhpUnused
*/
public function graphical_summary_expenses_categories(string $start_date, string $end_date, string $sale_type): void
public function graphical_summary_expenses_categories(string $start_date, string $end_date, string $sale_type): string
{
$this->clearCache();
@@ -750,7 +756,7 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -760,9 +766,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function graphical_summary_sales(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function graphical_summary_sales(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{
$this->clearCache();
@@ -796,7 +802,7 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -806,9 +812,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function graphical_summary_items(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function graphical_summary_items(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{
$this->clearCache();
@@ -843,7 +849,7 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -853,9 +859,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function graphical_summary_categories(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function graphical_summary_categories(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -886,7 +892,7 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -896,9 +902,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function graphical_summary_suppliers(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function graphical_summary_suppliers(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -931,7 +937,7 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -941,9 +947,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function graphical_summary_employees(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function graphical_summary_employees(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{
$this->clearCache();
@@ -975,7 +981,7 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -985,9 +991,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function graphical_summary_taxes(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function graphical_summary_taxes(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -1019,7 +1025,7 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -1029,9 +1035,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function graphical_summary_sales_taxes(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function graphical_summary_sales_taxes(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -1063,7 +1069,7 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -1073,9 +1079,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function graphical_summary_customers(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function graphical_summary_customers(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -1109,7 +1115,7 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -1120,9 +1126,10 @@ class Reports extends Secure_Controller
* @param string $sale_type
* @param string $location_id ID of the location to be reported or 'all' if none is specified
* @param int $discount_type
* @return string
* @noinspection PhpUnused
*/
public function graphical_summary_discounts(string $start_date, string $end_date, string $sale_type, string $location_id = 'all', int $discount_type = 0): void
public function graphical_summary_discounts(string $start_date, string $end_date, string $sale_type, string $location_id = 'all', int $discount_type = 0): string
{ // TODO: Duplicated Code
$this->clearCache();
@@ -1157,7 +1164,7 @@ class Reports extends Secure_Controller
'show_currency' => false
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
@@ -1167,9 +1174,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function graphical_summary_payments(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function graphical_summary_payments(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{
$this->clearCache();
@@ -1203,16 +1210,16 @@ class Reports extends Secure_Controller
'show_currency' => true
];
echo view('reports/graphical', $data);
return view('reports/graphical', $data);
}
/**
* Gets the specific customer input view. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function specific_customer_input(): void
public function specific_customer_input(): string
{
$this->clearCache();
@@ -1230,7 +1237,7 @@ class Reports extends Secure_Controller
$data['sale_type_options'] = $this->get_sale_type_options();
$data['payment_type'] = $this->get_payment_type();
echo view('reports/specific_customer_input', $data);
return view('reports/specific_customer_input', $data);
}
/**
@@ -1239,13 +1246,15 @@ class Reports extends Secure_Controller
public function get_payment_type(): array
{
return [
'all' => lang('Common.none_selected_text'),
'cash' => lang('Sales.cash'),
'due' => lang('Sales.due'),
'check' => lang('Sales.check'),
'credit' => lang('Sales.credit'),
'debit' => lang('Sales.debit'),
'invoices' => lang('Sales.invoice')
'all' => lang('Common.none_selected_text'),
'cash' => lang('Sales.cash'),
'due' => lang('Sales.due'),
'check' => lang('Sales.check'),
'credit' => lang('Sales.credit'),
'debit' => lang('Sales.debit'),
'bank_transfer' => lang('Sales.bank_transfer'),
'wallet' => lang('Sales.wallet'),
'invoices' => lang('Sales.invoice')
];
}
@@ -1257,10 +1266,10 @@ class Reports extends Secure_Controller
* @param string $customer_id
* @param string $sale_type
* @param string $payment_type
* @return void
* @return string
* @noinspection PhpUnused
*/
public function specific_customers(string $start_date, string $end_date, string $customer_id, string $sale_type, string $payment_type): void
public function specific_customers(string $start_date, string $end_date, string $customer_id, string $sale_type, string $payment_type): string
{
$this->clearCache();
@@ -1351,16 +1360,16 @@ class Reports extends Secure_Controller
'overall_summary_data' => $specific_customer->getSummaryData($inputs)
];
echo view('reports/tabular_details', $data);
return view('reports/tabular_details', $data);
}
/**
* Detailed employee report input form. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function specific_employee_input(): void
public function specific_employee_input(): string
{
$this->clearCache();
@@ -1374,7 +1383,7 @@ class Reports extends Secure_Controller
$data['specific_input_data'] = $employees;
$data['sale_type_options'] = $this->get_sale_type_options();
echo view('reports/specific_input', $data);
return view('reports/specific_input', $data);
}
/**
@@ -1384,10 +1393,10 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $employee_id
* @param string $sale_type
* @return void
* @return string
* @noinspection PhpUnused
*/
public function specific_employees(string $start_date, string $end_date, string $employee_id, string $sale_type): void
public function specific_employees(string $start_date, string $end_date, string $employee_id, string $sale_type): string
{
$this->clearCache();
@@ -1474,16 +1483,16 @@ class Reports extends Secure_Controller
'overall_summary_data' => $specific_employee->getSummaryData($inputs)
];
echo view('reports/tabular_details', $data);
return view('reports/tabular_details', $data);
}
/**
* Detailed discount report. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function specific_discount_input(): void
public function specific_discount_input(): string
{
$this->clearCache();
@@ -1498,7 +1507,7 @@ class Reports extends Secure_Controller
$data['discount_type_options'] = ['0' => lang('Reports.discount_percent'), '1' => lang('Reports.discount_fixed')];
$data['sale_type_options'] = $this->get_sale_type_options();
echo view('reports/specific_input', $data);
return view('reports/specific_input', $data);
}
/**
@@ -1509,10 +1518,10 @@ class Reports extends Secure_Controller
* @param string $discount
* @param string $sale_type
* @param string $discount_type
* @return void
* @return string
* @noinspection PhpUnused
*/
public function specific_discounts(string $start_date, string $end_date, string $discount, string $sale_type, string $discount_type): void
public function specific_discounts(string $start_date, string $end_date, string $discount, string $sale_type, string $discount_type): string
{
$this->clearCache();
@@ -1605,17 +1614,17 @@ class Reports extends Secure_Controller
'overall_summary_data' => $specific_discount->getSummaryData($inputs)
];
echo view('reports/tabular_details', $data);
return view('reports/tabular_details', $data);
}
/**
* Gets the detailed sales data row for given sale_id. Used in app/Views/reports/tabular_details.php
*
* @param string $sale_id
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getGet_detailed_sales_row(string $sale_id): void
public function getGet_detailed_sales_row(string $sale_id): ResponseInterface
{
$this->clearCache();
@@ -1658,16 +1667,16 @@ class Reports extends Secure_Controller
)
];
echo json_encode([$sale_id => $summary_data]);
return $this->response->setJSON([$sale_id => $summary_data]);
}
/**
* Detailed Supplier report input form. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function specific_supplier_input(): void
public function specific_supplier_input(): string
{
$this->clearCache();
@@ -1681,7 +1690,7 @@ class Reports extends Secure_Controller
$data['specific_input_data'] = $suppliers;
$data['sale_type_options'] = $this->get_sale_type_options();
echo view('reports/specific_input', $data);
return view('reports/specific_input', $data);
}
/**
@@ -1691,9 +1700,9 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $supplier_id
* @param string $sale_type
* @return void
* @return string
*/
public function specific_suppliers(string $start_date, string $end_date, string $supplier_id, string $sale_type): void
public function specific_suppliers(string $start_date, string $end_date, string $supplier_id, string $sale_type): string
{
$inputs = [
'start_date' => $start_date,
@@ -1727,7 +1736,7 @@ class Reports extends Secure_Controller
];
}
$supplier_info = $this->supplier->get_info($supplier_id);
$supplier_info = $this->supplier->get_info((int) $supplier_id);
$data = [
'title' => $supplier_info->company_name . ' (' . $supplier_info->first_name . ' ' . $supplier_info->last_name . ') ' . lang('Reports.report'),
'subtitle' => $this->_get_subtitle_report(['start_date' => $start_date, 'end_date' => $end_date]),
@@ -1736,7 +1745,7 @@ class Reports extends Secure_Controller
'summary_data' => $specific_supplier->getSummaryData($inputs)
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
@@ -1763,13 +1772,13 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $sale_type
* @param string $location_id
* @return void
* @return string
*/
public function detailed_sales(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): void
public function detailed_sales(string $start_date, string $end_date, string $sale_type, string $location_id = 'all'): string
{
$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,
@@ -1782,7 +1791,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;
@@ -1869,17 +1883,17 @@ class Reports extends Secure_Controller
'details_data_rewards' => $details_data_rewards,
'overall_summary_data' => $this->detailed_sales->getSummaryData($inputs)
];
echo view('reports/tabular_details', $data);
return view('reports/tabular_details', $data);
}
/**
* Returns detailed receivings row for given receiving_id. Used in app/Views/reports/tabular_details.php
*
* @param string $receiving_id
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getGet_detailed_receivings_row(string $receiving_id): void
public function getGet_detailed_receivings_row(string $receiving_id): ResponseInterface
{
$inputs = ['receiving_id' => $receiving_id];
@@ -1909,7 +1923,7 @@ class Reports extends Secure_Controller
)
];
echo json_encode([$receiving_id => $summary_data]);
return $this->response->setJSON([$receiving_id => $summary_data]);
}
/**
@@ -1917,20 +1931,25 @@ class Reports extends Secure_Controller
* @param string $end_date
* @param string $receiving_type
* @param string $location_id
* @return void
* @return string
*/
public function detailed_receivings(string $start_date, string $end_date, string $receiving_type, string $location_id = 'all'): void
public function detailed_receivings(string $start_date, string $end_date, string $receiving_type, string $location_id = 'all'): string
{
$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);
@@ -1993,13 +2012,13 @@ class Reports extends Secure_Controller
'overall_summary_data' => $this->detailed_receivings->getSummaryData($inputs)
];
echo view('reports/tabular_details', $data);
return view('reports/tabular_details', $data);
}
/**
* @return void
* @return string
*/
public function inventory_low(): void
public function inventory_low(): string
{
$this->clearCache();
@@ -2028,16 +2047,16 @@ class Reports extends Secure_Controller
'summary_data' => $inventory_low->getSummaryData($inputs)
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**
* Gets the inventory summary input view. Used in app/Config/Routes.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function inventory_summary_input(): void
public function inventory_summary_input(): string
{
$this->clearCache();
@@ -2048,15 +2067,15 @@ class Reports extends Secure_Controller
$stock_locations['all'] = lang('Reports.all');
$data['stock_locations'] = array_reverse($stock_locations, true);
echo view('reports/inventory_summary_input', $data);
return view('reports/inventory_summary_input', $data);
}
/**
* @param string $location_id
* @param string $item_count
* @return void
* @return string
*/
public function inventory_summary(string $location_id = 'all', string $item_count = 'all'): void
public function inventory_summary(string $location_id = 'all', string $item_count = 'all'): string
{
$this->clearCache();
@@ -2088,7 +2107,7 @@ class Reports extends Secure_Controller
'summary_data' => $this->inventory_summary->getSummaryData($report_data)
];
echo view('reports/tabular', $data);
return view('reports/tabular', $data);
}
/**

View File

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ namespace App\Controllers;
use App\Models\Employee;
use App\Models\Module;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Model;
use CodeIgniter\Session\Session;
use Config\OSPOS;
@@ -85,18 +85,17 @@ class Secure_Controller extends BaseController
/**
* AJAX function used to confirm whether values sent in the request are numeric
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getCheckNumeric(): void
public function getCheckNumeric(): ResponseInterface
{
foreach ($this->request->getGet() as $value) {
if (parse_decimals($value) === false) {
echo 'false';
return;
return $this->response->setJSON('false');
}
}
echo 'true';
return $this->response->setJSON('true');
}
/**

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Supplier;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class Suppliers extends Persons
@@ -17,33 +18,33 @@ class Suppliers extends Persons
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_suppliers_manage_table_headers();
echo view('people/manage', $data);
return view('people/manage', $data);
}
/**
* Gets one row for a supplier manage table. This is called using AJAX to update one row.
* @param $row_id
* @return void
* @return ResponseInterface
*/
public function getRow($row_id): void
public function getRow($row_id): ResponseInterface
{
$data_row = get_supplier_data_row($this->supplier->get_info($row_id));
$data_row['category'] = $this->supplier->get_category_name($data_row['category']);
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
* Returns Supplier table data rows. This will be called with AJAX.
* @return void
**/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
@@ -62,38 +63,39 @@ class Suppliers extends Persons
$data_rows[] = $row;
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* Gives search suggestions based on what is being searched for
* @return ResponseInterface
**/
public function getSuggest(): void
public function getSuggest(): ResponseInterface
{
$search = $this->request->getGet('term');
$suggestions = $this->supplier->get_search_suggestions($search, true);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* @return void
* @return ResponseInterface
*/
public function suggest_search(): void
public function suggest_search(): ResponseInterface
{
$search = $this->request->getPost('term');
$suggestions = $this->supplier->get_search_suggestions($search, false);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* Loads the supplier edit form
*
* @param int $supplier_id
* @return void
* @return string
*/
public function getView(int $supplier_id = NEW_ENTRY): void
public function getView(int $supplier_id = NEW_ENTRY): string
{
$info = $this->supplier->get_info($supplier_id);
foreach (get_object_vars($info) as $property => $value) {
@@ -102,16 +104,16 @@ class Suppliers extends Persons
$data['person_info'] = $info;
$data['categories'] = $this->supplier->get_categories();
echo view("suppliers/form", $data);
return view("suppliers/form", $data);
}
/**
* Inserts/updates a supplier
*
* @param int $supplier_id
* @return void
* @return ResponseInterface
*/
public function postSave(int $supplier_id = NEW_ENTRY): void
public function postSave(int $supplier_id = NEW_ENTRY): ResponseInterface
{
$first_name = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS); // TODO: Duplicate code
$last_name = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
@@ -147,21 +149,21 @@ class Suppliers extends Persons
if ($this->supplier->save_supplier($person_data, $supplier_data, $supplier_id)) {
// New supplier
if ($supplier_id == NEW_ENTRY) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Suppliers.successful_adding') . ' ' . $supplier_data['company_name'],
'id' => $supplier_data['person_id']
]);
} else { // Existing supplier
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Suppliers.successful_updating') . ' ' . $supplier_data['company_name'],
'id' => $supplier_id
]);
}
} else { // Failure
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Suppliers.error_adding_updating') . ' ' . $supplier_data['company_name'],
'id' => NEW_ENTRY
@@ -172,19 +174,19 @@ class Suppliers extends Persons
/**
* This deletes suppliers from the suppliers table
*
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$suppliers_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT);
if ($this->supplier->delete_list($suppliers_to_delete)) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Suppliers.successful_deleted') . ' ' . count($suppliers_to_delete) . ' ' . lang('Suppliers.one_or_multiple')
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Suppliers.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Suppliers.cannot_be_deleted')]);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Tax_category;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
/**
@@ -20,13 +21,13 @@ class Tax_categories extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['tax_categories_table_headers'] = get_tax_categories_table_headers();
echo view('taxes/tax_categories', $data);
return view('taxes/tax_categories', $data);
}
/**
@@ -34,12 +35,12 @@ class Tax_categories extends Secure_Controller
*
* @return void
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$sort = $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$sort = $this->sanitizeSortColumn(get_tax_categories_table_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'tax_category_id');
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$tax_categories = $this->tax_category->search($search, $limit, $offset, $sort, $order);
@@ -50,37 +51,37 @@ class Tax_categories extends Secure_Controller
$data_rows[] = get_tax_categories_data_row($tax_category);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* @param $row_id
* @return void
* @return ResponseInterface
*/
public function getRow($row_id): void
public function getRow($row_id): ResponseInterface
{
$data_row = get_tax_categories_data_row($this->tax_category->get_info($row_id));
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
* @param int $tax_category_id
* @return void
* @return string
*/
public function getView(int $tax_category_id = NEW_ENTRY): void
public function getView(int $tax_category_id = NEW_ENTRY): string
{
$data['tax_category_info'] = $this->tax_category->get_info($tax_category_id);
echo view("taxes/tax_category_form", $data);
return view("taxes/tax_category_form", $data);
}
/**
* @param int $tax_category_id
* @return void
* @return ResponseInterface
*/
public function postSave(int $tax_category_id = NEW_ENTRY): void
public function postSave(int $tax_category_id = NEW_ENTRY): ResponseInterface
{
$tax_category_data = [
'tax_category' => $this->request->getPost('tax_category', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
@@ -91,20 +92,20 @@ class Tax_categories extends Secure_Controller
if ($this->tax_category->save_value($tax_category_data, $tax_category_id)) {
// New tax_category_id
if ($tax_category_id == NEW_ENTRY) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Tax_categories.successful_adding'),
'id' => $tax_category_data['tax_category_id']
]);
} else {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Tax_categories.successful_updating'),
'id' => $tax_category_id
]);
}
} else {
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Tax_categories.error_adding_updating') . ' ' . $tax_category_data['tax_category'],
'id' => NEW_ENTRY
@@ -113,19 +114,19 @@ class Tax_categories extends Secure_Controller
}
/**
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$tax_categories_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT);
if ($this->tax_category->delete_list($tax_categories_to_delete)) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Tax_categories.successful_deleted') . ' ' . count($tax_categories_to_delete) . ' ' . lang('Tax_categories.one_or_multiple')
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Tax_categories.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Tax_categories.cannot_be_deleted')]);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Tax_code;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
/**
@@ -22,11 +23,11 @@ class Tax_codes extends Secure_Controller
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
echo view('taxes/tax_codes', $this->get_data());
return view('taxes/tax_codes', $this->get_data());
}
/**
@@ -44,12 +45,12 @@ class Tax_codes extends Secure_Controller
*
* @return void
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$sort = $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$sort = $this->sanitizeSortColumn(get_tax_code_table_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'tax_code');
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$tax_codes = $this->tax_code->search($search, $limit, $offset, $sort, $order);
@@ -61,37 +62,37 @@ class Tax_codes extends Secure_Controller
$data_rows[] = get_tax_code_data_row($tax_code);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* @param int $row_id
* @return void
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$data_row = get_tax_code_data_row($this->tax_code->get_info($row_id));
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
* @param int $tax_code_id
* @return void
* @return string
*/
public function getView(int $tax_code_id = NEW_ENTRY): void
public function getView(int $tax_code_id = NEW_ENTRY): string
{
$data['tax_code_info'] = $this->tax_code->get_info($tax_code_id);
echo view("taxes/tax_code_form", $data);
return view("taxes/tax_code_form", $data);
}
/**
* @param int $tax_code_id
* @return void
* @return ResponseInterface
*/
public function postSave(int $tax_code_id = NEW_ENTRY): void
public function postSave(int $tax_code_id = NEW_ENTRY): ResponseInterface
{
$tax_code_data = [
'tax_code' => $this->request->getPost('tax_code', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
@@ -102,20 +103,20 @@ class Tax_codes extends Secure_Controller
if ($this->tax_code->save($tax_code_data)) {
if ($tax_code_id == NEW_ENTRY) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Tax_codes.successful_adding'),
'id' => $tax_code_data['tax_code_id']
]);
} else {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Tax_codes.successful_updating'),
'id' => $tax_code_id
]);
}
} else {
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Tax_codes.error_adding_updating') . ' ' . $tax_code_data['tax_code_id'],
'id' => NEW_ENTRY
@@ -124,19 +125,19 @@ class Tax_codes extends Secure_Controller
}
/**
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$tax_codes_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT);
if ($this->tax_code->delete_list($tax_codes_to_delete)) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Tax_codes.successful_deleted') . ' ' . count($tax_codes_to_delete) . ' ' . lang('Tax_codes.one_or_multiple')
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Tax_codes.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Tax_codes.cannot_be_deleted')]);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Controllers;
use App\Models\Tax_jurisdiction;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
/**
@@ -23,13 +24,13 @@ class Tax_jurisdictions extends Secure_Controller
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['table_headers'] = get_tax_jurisdictions_table_headers();
echo view('taxes/tax_jurisdictions', $data);
return view('taxes/tax_jurisdictions', $data);
}
/**
@@ -37,12 +38,12 @@ class Tax_jurisdictions extends Secure_Controller
*
* @return void
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$sort = $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$sort = $this->sanitizeSortColumn(get_tax_jurisdictions_table_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'jurisdiction_id');
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$tax_jurisdictions = $this->tax_jurisdiction->search($search, $limit, $offset, $sort, $order);
@@ -53,37 +54,37 @@ class Tax_jurisdictions extends Secure_Controller
$data_rows[] = get_tax_jurisdictions_data_row($tax_jurisdiction);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* @param int $row_id
* @return void
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$data_row = get_tax_jurisdictions_data_row($this->tax_jurisdiction->get_info($row_id));
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
* @param int $tax_jurisdiction_id
* @return void
* @return string
*/
public function getView(int $tax_jurisdiction_id = NEW_ENTRY): void
public function getView(int $tax_jurisdiction_id = NEW_ENTRY): string
{
$data['tax_jurisdiction_info'] = $this->tax_jurisdiction->get_info($tax_jurisdiction_id);
echo view("taxes/tax_jurisdiction_form", $data);
return view("taxes/tax_jurisdiction_form", $data);
}
/**
* @param int $jurisdiction_id
* @return void
* @return ResponseInterface
*/
public function postSave(int $jurisdiction_id = NEW_ENTRY): void
public function postSave(int $jurisdiction_id = NEW_ENTRY): ResponseInterface
{
$tax_jurisdiction_data = [
'jurisdiction_name' => $this->request->getPost('jurisdiction_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
@@ -92,20 +93,20 @@ class Tax_jurisdictions extends Secure_Controller
if ($this->tax_jurisdiction->save_value($tax_jurisdiction_data)) {
if ($jurisdiction_id == NEW_ENTRY) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Tax_jurisdictions.successful_adding'),
'id' => $tax_jurisdiction_data['jurisdiction_id']
]);
} else {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Tax_jurisdictions.successful_updating'),
'id' => $jurisdiction_id
]);
}
} else {
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Tax_jurisdictions.error_adding_updating') . ' ' . $tax_jurisdiction_data['jurisdiction_name'],
'id' => NEW_ENTRY
@@ -114,19 +115,19 @@ class Tax_jurisdictions extends Secure_Controller
}
/**
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$tax_jurisdictions_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT);
if ($this->tax_jurisdiction->delete_list($tax_jurisdictions_to_delete)) {
echo json_encode([
return $this->response->setJSON([
'success' => true,
'message' => lang('Tax_jurisdictions.successful_deleted') . ' ' . count($tax_jurisdictions_to_delete) . ' ' . lang('Tax_jurisdictions.one_or_multiple')
]);
} else {
echo json_encode(['success' => false, 'message' => lang('Tax_jurisdictions.cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Tax_jurisdictions.cannot_be_deleted')]);
}
}
}

View File

@@ -3,11 +3,12 @@
namespace App\Controllers;
use App\Libraries\Tax_lib;
use App\Models\enums\Rounding_mode;
use App\Models\Enums\Rounding_mode;
use App\Models\Tax;
use App\Models\Tax_category;
use App\Models\Tax_code;
use App\Models\Tax_jurisdiction;
use CodeIgniter\HTTP\ResponseInterface;
use Config\OSPOS;
use Config\Services;
@@ -36,9 +37,9 @@ class Taxes extends Secure_Controller
}
/**
* @return void
* @return string
*/
public function getIndex(): void
public function getIndex(): string
{
$data['tax_codes'] = $this->tax_code->get_all()->getResultArray();
if (count($data['tax_codes']) == 0) {
@@ -67,7 +68,7 @@ class Taxes extends Secure_Controller
$data['tax_type_options'] = $this->tax_lib->get_tax_type_options($data['default_tax_type']);
echo view('taxes/manage', $data);
return view('taxes/manage', $data);
}
/**
@@ -75,12 +76,12 @@ class Taxes extends Secure_Controller
*
* @return void
*/
public function getSearch(): void
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$sort = $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$sort = $this->sanitizeSortColumn(get_tax_rates_manage_table_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'tax_rate_id');
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$tax_rates = $this->tax->search($search, $limit, $offset, $sort, $order);
@@ -92,50 +93,50 @@ class Taxes extends Secure_Controller
$data_rows[] = get_tax_rates_data_row($tax_rate_row);
}
echo json_encode(['total' => $total_rows, 'rows' => $data_rows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* Gives search suggestions based on what is being searched for
* @return ResponseInterface
*/
public function suggest_search(): void
public function suggest_search(): ResponseInterface
{
$search = $this->request->getPost('term');
$suggestions = $this->tax->get_search_suggestions($search); // TODO: There is no get_search_suggestions function in the tax model
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* Provides list of tax categories to select from
*
* @return void
* @return ResponseInterface
*/
public function suggest_tax_categories(): void
public function suggest_tax_categories(): ResponseInterface
{
$search = $this->request->getPost('term');
$suggestions = $this->tax_category->get_tax_category_suggestions($search);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* @param int $row_id
* @return void
* @return ResponseInterface
*/
public function getRow(int $row_id): void
public function getRow(int $row_id): ResponseInterface
{
$data_row = get_tax_rates_data_row($this->tax->get_info($row_id));
echo json_encode($data_row);
return $this->response->setJSON($data_row);
}
/**
* @param int $tax_code
* @return void
* @return string
*/
public function getView_tax_codes(int $tax_code = NEW_ENTRY): void
public function getView_tax_codes(int $tax_code = NEW_ENTRY): string
{
$tax_code_info = $this->tax->get_info($tax_code);
@@ -150,7 +151,7 @@ class Taxes extends Secure_Controller
$data['default_tax_type'] = Tax_lib::TAX_TYPE_EXCLUDED;
}
$data['rounding_options'] = rounding_mode::get_rounding_options();
$data['rounding_options'] = Rounding_mode::get_rounding_options();
$data['html_rounding_options'] = $this->get_html_rounding_options();
if ($tax_code == NEW_ENTRY) { // TODO: Duplicated code
@@ -192,20 +193,20 @@ class Taxes extends Secure_Controller
$data['tax_rates'] = $tax_rates;
echo view('taxes/tax_code_form', $data);
return view('taxes/tax_code_form', $data);
}
/**
* @param int $tax_rate_id
* @return void
* @return string
*/
public function getView(int $tax_rate_id = NEW_ENTRY): void
public function getView(int $tax_rate_id = NEW_ENTRY): string
{
$tax_rate_info = $this->tax->get_info($tax_rate_id);
$data['tax_rate_id'] = $tax_rate_id;
$data['rounding_options'] = rounding_mode::get_rounding_options();
$data['rounding_options'] = Rounding_mode::get_rounding_options();
$data['tax_code_options'] = $this->tax_lib->get_tax_code_options();
$data['tax_category_options'] = $this->tax_lib->get_tax_category_options();
@@ -215,7 +216,7 @@ class Taxes extends Secure_Controller
$data['rate_tax_code_id'] = $this->config['default_tax_code'];
$data['rate_tax_category_id'] = $this->config['default_tax_category'];
$data['rate_jurisdiction_id'] = $this->config['default_tax_jurisdiction'];
$data['tax_rounding_code'] = rounding_mode::HALF_UP;
$data['tax_rounding_code'] = Rounding_mode::HALF_UP;
$data['tax_rate'] = '0.0000';
} else {
$data['rate_tax_code_id'] = $tax_rate_info->rate_tax_code_id;
@@ -226,14 +227,14 @@ class Taxes extends Secure_Controller
$data['tax_rate'] = $tax_rate_info->tax_rate;
}
echo view('taxes/tax_rates_form', $data);
return view('taxes/tax_rates_form', $data);
}
/**
* @param int $tax_code
* @return void
* @return string
*/
public function getView_tax_categories(int $tax_code = NEW_ENTRY): void // TODO: This appears to be called no where in the code.
public function getView_tax_categories(int $tax_code = NEW_ENTRY): string // TODO: This appears to be called no where in the code.
{
$tax_code_info = $this->tax->get_info($tax_code); // TODO: Duplicated Code
@@ -242,7 +243,7 @@ class Taxes extends Secure_Controller
$tax_rate_info = $this->tax->get_rate_info($tax_code, $default_tax_category_id);
$data['rounding_options'] = rounding_mode::get_rounding_options();
$data['rounding_options'] = Rounding_mode::get_rounding_options();
$data['html_rounding_options'] = $this->get_html_rounding_options();
if ($this->config['tax_included']) {
@@ -290,14 +291,14 @@ class Taxes extends Secure_Controller
$data['tax_rates'] = $tax_rates;
echo view('taxes/tax_category_form', $data);
return view('taxes/tax_category_form', $data);
}
/**
* @param int $tax_code
* @return void
* @return string
*/
public function getView_tax_jurisdictions(int $tax_code = NEW_ENTRY): void // TODO: This appears to be called no where in the code.
public function getView_tax_jurisdictions(int $tax_code = NEW_ENTRY): string // TODO: This appears to be called no where in the code.
{
$tax_code_info = $this->tax->get_info($tax_code); // TODO: Duplicated code
@@ -306,7 +307,7 @@ class Taxes extends Secure_Controller
$tax_rate_info = $this->tax->get_rate_info($tax_code, $default_tax_category_id);
$data['rounding_options'] = rounding_mode::get_rounding_options();
$data['rounding_options'] = Rounding_mode::get_rounding_options();
$data['html_rounding_options'] = $this->get_html_rounding_options();
if ($this->config['tax_included']) {
@@ -354,7 +355,7 @@ class Taxes extends Secure_Controller
$data['tax_rates'] = $tax_rates;
echo view('taxes/tax_jurisdiction_form', $data);
return view('taxes/tax_jurisdiction_form', $data);
}
/**
@@ -362,14 +363,14 @@ class Taxes extends Secure_Controller
*/
public static function get_html_rounding_options(): string
{
return rounding_mode::get_html_rounding_options();
return Rounding_mode::get_html_rounding_options();
}
/**
* @param int $tax_rate_id
* @return void
* @return ResponseInterface
*/
public function postSave(int $tax_rate_id = NEW_ENTRY): void
public function postSave(int $tax_rate_id = NEW_ENTRY): ResponseInterface
{
$tax_category_id = $this->request->getPost('rate_tax_category_id', FILTER_SANITIZE_NUMBER_INT);
$tax_rate = parse_tax($this->request->getPost('tax_rate'));
@@ -388,50 +389,50 @@ class Taxes extends Secure_Controller
if ($this->tax->save_value($tax_rate_data, $tax_rate_id)) {
if ($tax_rate_id == NEW_ENTRY) { // TODO: this needs to be replaced with ternary notation
echo json_encode(['success' => true, 'message' => lang('Taxes.tax_rate_successfully_added')]);
return $this->response->setJSON(['success' => true, 'message' => lang('Taxes.tax_rate_successfully_added')]);
} else { // Existing tax_code
echo json_encode(['success' => true, 'message' => lang('Taxes.tax_rate_successful_updated')]);
return $this->response->setJSON(['success' => true, 'message' => lang('Taxes.tax_rate_successful_updated')]);
}
} else {
echo json_encode(['success' => false, 'message' => lang('Taxes.tax_rate_error_adding_updating')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Taxes.tax_rate_error_adding_updating')]);
}
}
/**
* @return void
* @return ResponseInterface
*/
public function postDelete(): void
public function postDelete(): ResponseInterface
{
$tax_codes_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT);
if ($this->tax->delete_list($tax_codes_to_delete)) { // TODO: this needs to be replaced with ternary notation
echo json_encode(['success' => true, 'message' => lang('Taxes.tax_code_successful_deleted')]);
return $this->response->setJSON(['success' => true, 'message' => lang('Taxes.tax_code_successful_deleted')]);
} else {
echo json_encode(['success' => false, 'message' => lang('Taxes.tax_code_cannot_be_deleted')]);
return $this->response->setJSON(['success' => false, 'message' => lang('Taxes.tax_code_cannot_be_deleted')]);
}
}
/**
* Get search suggestions for tax codes. Used in app/Views/customers/form.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getSuggestTaxCodes(): void
public function getSuggestTaxCodes(): ResponseInterface
{
$search = $this->request->getPostGet('term');
$suggestions = $this->tax_code->get_tax_codes_search_suggestions($search);
echo json_encode($suggestions);
return $this->response->setJSON($suggestions);
}
/**
* Saves Tax Codes. Used in app/Views/taxes/tax_codes.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function save_tax_codes(): void
public function postSave_tax_codes(): ResponseInterface
{
$tax_code_id = $this->request->getPost('tax_code_id', FILTER_SANITIZE_NUMBER_INT);
$tax_code = $this->request->getPost('tax_code', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
@@ -452,7 +453,7 @@ class Taxes extends Secure_Controller
$success = $this->tax_code->save_tax_codes($array_save);
echo json_encode([
return $this->response->setJSON([
'success' => $success,
'message' => lang('Taxes.tax_codes_saved_' . ($success ? '' : 'un') . 'successfully')
]);
@@ -461,10 +462,10 @@ class Taxes extends Secure_Controller
/**
* Saves given tax jurisdiction. Used in app/Views/taxes/tax_jurisdictions.php.
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function save_tax_jurisdictions(): void
public function postSave_tax_jurisdictions(): ResponseInterface
{
$jurisdiction_id = $this->request->getPost('jurisdiction_id', FILTER_SANITIZE_NUMBER_INT);
$jurisdiction_name = $this->request->getPost('jurisdiction_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
@@ -489,11 +490,10 @@ class Taxes extends Secure_Controller
];
if (in_array($tax_group[$key], $unique_tax_groups)) { // TODO: This can be replaced with `in_array($tax_group[$key], $unique_tax_groups)`
echo json_encode([
return $this->response->setJSON([
'success' => false,
'message' => lang('Taxes.tax_group_not_unique', [$tax_group[$key]])
]);
return;
} else {
$unique_tax_groups[] = $tax_group[$key];
}
@@ -501,7 +501,7 @@ class Taxes extends Secure_Controller
$success = $this->tax_jurisdiction->save_jurisdictions($array_save);
echo json_encode([
return $this->response->setJSON([
'success' => $success,
'message' => lang('Taxes.tax_jurisdictions_saved_' . ($success ? '' : 'un') . 'successfully')
]);
@@ -510,10 +510,10 @@ class Taxes extends Secure_Controller
/**
* Saves tax categories. Used in app/Views/taxes/tax_categories.php
*
* @return void
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function save_tax_categories(): void
public function postSave_tax_categories(): ResponseInterface
{
$tax_category_id = $this->request->getPost('tax_category_id', FILTER_SANITIZE_NUMBER_INT);
$tax_category = $this->request->getPost('tax_category', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
@@ -531,7 +531,7 @@ class Taxes extends Secure_Controller
$success = $this->tax_category->save_categories($array_save);
echo json_encode([
return $this->response->setJSON([
'success' => $success,
'message' => lang('Taxes.tax_categories_saved_' . ($success ? '' : 'un') . 'successfully')
]);
@@ -540,36 +540,36 @@ class Taxes extends Secure_Controller
/**
* Gets tax codes partial view. Used in app/Views/taxes/tax_codes.php.
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function ajax_tax_codes(): void
public function getAjax_tax_codes(): string
{
$tax_codes = $this->tax_code->get_all()->getResultArray();
echo view('partial/tax_codes', ['tax_codes' => $tax_codes]);
return view('partial/tax_codes', ['tax_codes' => $tax_codes]);
}
/**
* Gets current tax categories. Used in app/Views/taxes/tax_categories.php
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function ajax_tax_categories(): void
public function getAjax_tax_categories(): string
{
$tax_categories = $this->tax_category->get_all()->getResultArray();
echo view('partial/tax_categories', ['tax_categories' => $tax_categories]);
return view('partial/tax_categories', ['tax_categories' => $tax_categories]);
}
/**
* Gets the tax jurisdiction partial view. Used in app/Views/taxes/tax_jurisdictions.php.
*
* @return void
* @return string
* @noinspection PhpUnused
*/
public function ajax_tax_jurisdictions(): void
public function getAjax_tax_jurisdictions(): string
{
$tax_jurisdictions = $this->tax_jurisdiction->get_all()->getResultArray();
@@ -581,7 +581,7 @@ class Taxes extends Secure_Controller
$tax_types = $this->tax_lib->get_tax_types();
echo view('partial/tax_jurisdictions', [
return view('partial/tax_jurisdictions', [
'tax_jurisdictions' => $tax_jurisdictions,
'tax_types' => $tax_types,
'default_tax_type' => $default_tax_type

View File

@@ -1,5 +1,5 @@
FROM alpine:3.14
MAINTAINER jekkos
LABEL maintainer="jekkos"
ADD database.sql /docker-entrypoint-initdb.d/database.sql
VOLUME /docker-entrypoint-initdb.d

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

@@ -2,6 +2,7 @@
namespace App\Database\Migrations;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Database\Migration;
class Migration_Upgrade_To_3_1_1 extends Migration
@@ -17,7 +18,37 @@ class Migration_Upgrade_To_3_1_1 extends Migration
public function up(): void
{
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.0.2_to_3.1.1.sql');
// MariaDB blocks CONVERT TO CHARACTER SET on tables with FK constraints.
// Drop all FKs across affected tables before running the SQL script, recreate after.
$fkColumns = [
['modules', 'module_id'],
['stock_locations', 'location_id'],
['permissions', 'permission_id'],
['people', 'person_id'],
['suppliers', 'supplier_id'],
['items', 'item_id'],
['item_kits', 'item_kit_id'],
['sales', 'sale_id'],
['receivings', 'receiving_id'],
['employees', 'employee_id'],
['customers', 'person_id'],
];
$constraints = [];
foreach ($fkColumns as [$table, $column]) {
foreach (dropAllForeignKeyConstraints($table, $column) as $c) {
$constraints[$c['constraintName']] = $c;
}
}
if (!execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.0.2_to_3.1.1.sql')) {
throw new DatabaseException('Migration script 3.0.2_to_3.1.1.sql failed. Check logs for details.');
}
$droppedTables = ['sales_suspended', 'sales_suspended_items', 'sales_suspended_items_taxes', 'sales_suspended_payments'];
$toRecreate = array_filter($constraints, fn($c) => !in_array($c['tableName'], $droppedTables, true));
recreateForeignKeyConstraints(array_values($toRecreate));
}
/**

View File

@@ -34,7 +34,7 @@ class Migration_Sales_Tax_Data extends Migration
public function up(): void
{
$number_of_unmigrated = $this->get_count_of_unmigrated();
error_log("Migrating sales tax history. The number of sales that will be migrated is $number_of_unmigrated");
log_message('info', "Migrating sales tax history. The number of sales that will be migrated is $number_of_unmigrated");
if ($number_of_unmigrated > 0) {
$unmigrated_invoices = $this->get_unmigrated($number_of_unmigrated)->getResultArray();
@@ -44,7 +44,7 @@ class Migration_Sales_Tax_Data extends Migration
}
}
error_log('Migrating sales tax history. The number of sales that will be migrated is finished.');
log_message('info', 'Migrating sales tax history. The number of sales that will be migrated is finished.');
}
/**
@@ -146,7 +146,7 @@ class Migration_Sales_Tax_Data extends Migration
. ' ORDER BY SIT.sale_id) as US')->getResultArray();
if (!$result) {
error_log('Database error in 20170502221506_sales_tax_data.php related to sales_taxes or sales_items_taxes.');
log_message('info', 'Database error in 20170502221506_sales_tax_data.php related to sales_taxes or sales_items_taxes.');
return 0;
}
@@ -267,6 +267,8 @@ class Migration_Sales_Tax_Data extends Migration
*/
public function round_number(int $rounding_mode, string $amount, int $decimals): float
{
$amount = (float)$amount;
if ($rounding_mode == Migration_Sales_Tax_Data::ROUND_UP) {
$fig = pow(10, $decimals);
$rounded_total = (ceil($fig * $amount) + ceil($fig * $amount - ceil($fig * $amount))) / $fig;
@@ -376,7 +378,7 @@ class Migration_Sales_Tax_Data extends Migration
$decimals = totals_decimals();
foreach ($sales_taxes as $row_number => $sales_tax) {
$sale_tax_amount = $sales_tax['sale_tax_amount'];
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
$rounding_code = $sales_tax['rounding_code'];
$rounded_sale_tax_amount = $sale_tax_amount;

View File

@@ -19,8 +19,6 @@ class Migration_IndiaGST extends Migration
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_indiagst.sql');
error_log('Migrating tax configuration');
$count_of_tax_codes = $this->get_count_of_tax_code_entries();
if ($count_of_tax_codes > 0) {
@@ -42,8 +40,6 @@ class Migration_IndiaGST extends Migration
}
$this->drop_backups();
error_log('Migrating tax configuration completed');
}
/**

View File

@@ -13,10 +13,6 @@ class Migration_IndiaGST1 extends Migration
{
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.0_indiagst1.sql');
error_log('Fix definition of Supplier.Tax Id');
error_log('Definition of Supplier.Tax Id corrected');
}
/**

View File

@@ -11,6 +11,8 @@ class Migration_fix_empty_reports extends Migration
*/
public function up(): void
{
log_message('info', 'Starting migration: Fix empty reports.');
$builder = $this->db->table('stock_locations');
$builder->select('location_name');
$builder->where('location_id', 1);
@@ -23,6 +25,8 @@ class Migration_fix_empty_reports extends Migration
$builder->where('permission_id', 'receivings_' . $location_name);
$builder->orWhere('permission_id', 'sales_' . $location_name);
$builder->update();
log_message('info', 'Finished migration: Fix empty reports.');
}
/**

View File

@@ -11,6 +11,7 @@ class Migration_receipttaxindicator extends Migration
*/
public function up(): void
{
log_message('info', 'Migrating receipt tax indicator.');
$this->db->query('INSERT INTO ' . $this->db->prefixTable('app_config') . ' (`key`, `value`)
VALUES (\'receipt_show_tax_ind\', \'0\')');
}
@@ -20,6 +21,6 @@ class Migration_receipttaxindicator extends Migration
*/
public function down(): void
{
$this->db->query('DELETE FROM ' . $this->db->prefixTable('app_config') . ' WHERE key = \'receipt_show_tax_ind\'');
$this->db->query('DELETE FROM ' . $this->db->prefixTable('app_config') . ' WHERE `key` = \'receipt_show_tax_ind\'');
}
}

View File

@@ -41,7 +41,7 @@ class Migration_TaxAmount extends Migration
$tax_decimals = $this->appconfig->get_value('tax_decimals', 2);
$number_of_unmigrated = $this->get_count_of_unmigrated();
error_log('Migrating sales tax fixing. The number of sales that will be migrated is ' . $number_of_unmigrated);
log_message('info', 'Migrating sales tax fixing. The number of sales that will be migrated is ' . $number_of_unmigrated);
if ($number_of_unmigrated > 0) {
$unmigrated_invoices = $this->get_unmigrated($number_of_unmigrated)->getResultArray();
@@ -54,7 +54,7 @@ class Migration_TaxAmount extends Migration
$this->db->query('DROP TABLE ' . $this->db->prefixTable('sales_taxes_backup'));
}
error_log('Migrating sales tax fixing. The number of sales that will be migrated is finished.');
log_message('info', 'Migrating sales tax fixing. The number of sales that will be migrated is finished.');
}
}
@@ -126,7 +126,7 @@ class Migration_TaxAmount extends Migration
. ' ORDER BY SIT.sale_id) as US')->getResultArray();
if (!$result) {
error_log('Database error in 20200202000000_taxamount.php related to sales_taxes or sales_items_taxes.');
log_message('info', 'Database error in 20200202000000_taxamount.php related to sales_taxes or sales_items_taxes.');
return 0;
}
@@ -243,6 +243,8 @@ class Migration_TaxAmount extends Migration
*/
public function round_number(int $rounding_mode, string $amount, int $decimals): float // TODO: is this currency safe?
{ // TODO: This needs to be converted to a switch
$amount = (float)$amount;
if ($rounding_mode == Migration_TaxAmount::ROUND_UP) { // TODO: === ?
$fig = pow(10, $decimals);
$rounded_total = (ceil($fig * $amount) + ceil($fig * $amount - ceil($fig * $amount))) / $fig;
@@ -354,7 +356,7 @@ class Migration_TaxAmount extends Migration
$decimals = totals_decimals();
foreach ($sales_taxes as $row_number => $sales_tax) {
$sale_tax_amount = $sales_tax['sale_tax_amount'];
$sale_tax_amount = (float)$sales_tax['sale_tax_amount'];
$rounding_code = $sales_tax['rounding_code'];
$rounded_sale_tax_amount = $sale_tax_amount;

View File

@@ -11,6 +11,7 @@ class Migration_taxgroupconstraint extends Migration
*/
public function up(): void
{
log_message('info', 'Migrating tax group constraints.');
$this->db->query('ALTER TABLE ' . $this->db->prefixTable('tax_jurisdictions') . ' ADD CONSTRAINT tax_jurisdictions_uq1 UNIQUE (tax_group)');
}

View File

@@ -11,6 +11,7 @@ class Migration_image_upload_defaults extends Migration
*/
public function up(): void
{
log_message('info', 'Migrating image upload defaults.');
$image_values = [
['key' => 'image_allowed_types', 'value' => 'gif|jpg|png'],
['key' => 'image_max_height', 'value' => '480'],

View File

@@ -11,12 +11,8 @@ class Migration_modify_attr_links_constraint extends Migration
*/
public function up(): void
{
error_log('Migrating modify_attr_links_constraint');
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.2_modify_attr_links_constraint.sql');
error_log('Migrating modify_attr_links_constraint');
}
/**

View File

@@ -11,6 +11,7 @@ class Migration_cashrounding extends Migration
*/
public function up(): void
{
log_message('info', 'Migrating cash rounding.');
$this->db->query('ALTER TABLE ' . $this->db->prefixTable('sales_payments') . ' ADD COLUMN `cash_adjustment` tinyint NOT NULL DEFAULT 0 AFTER `cash_refund`');
}

View File

@@ -11,12 +11,8 @@ class Migration_add_item_kit_number extends Migration
*/
public function up(): void
{
error_log('Migrating add_item_kit_number');
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.3_add_kits_item_number.sql');
error_log('Migrating add_item_kit_number');
}
/**

View File

@@ -11,12 +11,8 @@ class Migration_modify_session_datatype extends Migration
*/
public function up(): void
{
error_log('Migrating modify_session_datatype');
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.3.4_modify_session_datatype.sql');
error_log('Migrating modify_session_datatype');
}
/**

View File

@@ -16,11 +16,11 @@ class Migration_database_optimizations extends Migration
*/
public function up(): void
{
error_log('Migrating database_optimizations');
log_message('info', 'Migrating database optimizations.');
$attribute = model(Attribute::class);
$attribute->delete_orphaned_values();
$attribute->deleteOrphanedValues();
$this->migrate_duplicate_attribute_values(DECIMAL);
$this->migrate_duplicate_attribute_values(DATE);
@@ -82,7 +82,7 @@ class Migration_database_optimizations extends Migration
helper('migration');
execute_script(APPPATH . 'Database/Migrations/sqlscripts/3.4.0_database_optimizations.sql');
error_log('Migrating database_optimizations completed');
log_message('info', 'Finished migrating database optimizations.');
}
/**

View File

@@ -12,11 +12,11 @@ class Migration_remove_duplicate_links extends Migration
*/
public function up(): void
{
error_log('Migrating remove_duplicate_links');
log_message('info', 'Removing duplicate links.');
$this->migrate_duplicate_attribute_links();
error_log('Migrating remove_duplicate_links completed');
log_message('info', 'Duplicate links removed.');
}
/**

View File

@@ -11,11 +11,11 @@ class Migration_move_expenses_categories extends Migration
*/
public function up(): void
{
error_log('Migrating expense categories module');
log_message('info', 'Migrating expense categories module');
$this->db->simpleQuery("UPDATE ospos_grants SET menu_group = 'office' WHERE permission_id = 'expenses_categories'");
error_log('Migrating expense categories module completed');
log_message('info', 'Migrating expense categories module completed');
}
/**

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