Compare commits

..

48 Commits

Author SHA1 Message Date
Ollama
61cc600909 fix: address PR review comments
- Add missing 'branch' output to build job (fixes master detection)
- Guard Docker publish against fork PRs (secrets unavailable)
- Use consistent 7-char SHA from build outputs in release job
- Add pipefail to install script test to preserve exit status
2026-05-22 14:53:16 +02:00
Ollama
ccc594c377 fix: centralize version tag logic across workflows
Previously, version tags were generated inconsistently:
- build-release.yml: {VERSION}-{BRANCH}-{SHA-6}
- deploy-pr.yml: pr-{PR_NUMBER}-{SHA-7}

Additionally, deploy-pr.yml was broken because Docker images weren't
built for PR events, causing deployment failures.

Changes:
- Create shared .github/scripts/get-version.sh for version tag generation
- Standardize SHA length to 7 characters across all workflows
- Enable Docker builds for pull_request events in build-release.yml
- Update deploy-pr.yml to use shared script

Tag formats are now:
- PRs: pr-{NUMBER}-{SHA-7}
- Master push: {VERSION} (+ latest tag)
- Feature branches: {VERSION}-{BRANCH}-{SHA-7}
2026-05-19 09:03:46 +02:00
Ollama
1caea1b762 fix: escape special characters in passwords for sed
Base64 passwords can contain /, +, = which break sed with |
delimiter. Also escape & and \ characters for safety.
2026-05-19 08:57:32 +02:00
Ollama
b77f094650 fix: address remaining CodeRabbit issues
- Add SQL injection validation for DB_NAME, DB_USER, DB_HOST
- Honor OSPOS_DIR during extraction (was hardcoded to /var/www)
- Fix SSL_EMAIL alone enabling Let's Encrypt (use APACHE_SERVER_NAME as domain)
- Quote OSPOS_DIR variables to prevent word splitting
2026-05-19 08:57:32 +02:00
Ollama
1a9a080358 fix: address CodeRabbit review comments
- Remove duplicate php-gd package
- Disable directory listing (Options -Indexes)
- Propagate MYSQL_ROOT_PASS to all mysql commands
- Fix allowedHostnames sed pattern to match .env.example format
2026-05-19 08:57:32 +02:00
Ollama
71fe6ac6e4 docs: add missing env vars and testing note to INSTALL.md 2026-05-19 08:57:32 +02:00
Ollama
a854132637 chore: remove debug output after fix verification 2026-05-19 08:57:32 +02:00
Ollama
6f4448d09b fix: use | delimiter in sed to handle special characters in passwords
The / delimiter was breaking when passwords contain special chars like !
2026-05-19 08:57:32 +02:00
Ollama
cb847414b9 fix: copy hidden files from extraction directory
Hidden files like .env were not being copied because shell glob *
doesn't match hidden files. Use 'ospos-temp/.' to copy all files.
2026-05-19 08:57:32 +02:00
Ollama
e76421e49c fix: add debug output to trace .env configuration 2026-05-19 08:57:32 +02:00
Ollama
f76acbb693 fix: add verification output for .env configuration 2026-05-19 08:57:32 +02:00
Ollama
acbb3c1639 fix: generate encryption key for OSPOS
The release .env has an empty encryption key which causes
HTTP 500 on startup. Generate a random key if the value is empty.
2026-05-19 08:57:32 +02:00
Ollama
44e3b4c4dc ci: improve debugging for .env and PHP errors 2026-05-19 08:57:32 +02:00
Ollama
1d40e7d5f5 fix: match quoted values in .env file for sed substitutions
The .env file from release uses quoted values like 'localhost'
but sed patterns were looking for unquoted values, causing
database credentials to not be updated.
2026-05-19 08:57:32 +02:00
Ollama
e98bf38af6 fix: configure .env even when .env.example doesn't exist
Release zip contains .env directly, not .env.example. The sed commands
to update database credentials were being skipped because the file check
looked for .env.example first, which doesn't exist in published releases.
2026-05-19 08:57:32 +02:00
Ollama
65fdea0807 ci: add more detailed debugging for HTTP 500 errors 2026-05-19 08:57:32 +02:00
Ollama
8172dc23f7 ci: add debug logging for HTTP 500 errors 2026-05-19 08:57:32 +02:00
Ollama
98d703dc27 fix: extract release zip directly to OSPOS directory
The release zip files extract at root level without a subdirectory
2026-05-19 08:57:32 +02:00
Ollama
c589367479 fix: remove hardcoded OSPOS_VERSION from CI workflow
Let the install script use the latest release by default
2026-05-19 08:57:32 +02:00
Ollama
1747332282 fix: use correct release asset URL from GitHub API
Releases use opensourcepos.VERSION.HASH.zip naming format, not
opensourcepos-VERSION.zip. This fix fetches the actual asset URL
from the GitHub API and extracts the correct directory name.
2026-05-19 08:57:32 +02:00
Ollama
f78a24862e fix: add ondrej/php PPA when PHP version not in default repos
The install script was failing on Ubuntu 22.04 because PHP 8.2 is not
available in default repositories. This fix checks if the requested
PHP version is available, and if not, adds the ondrej/php PPA which
provides all supported PHP versions.
2026-05-19 08:57:32 +02:00
Ollama
f2a57ff5bb ci: add install script test workflow
- Tests install script on Ubuntu 22.04 runner
- Verifies Apache, MariaDB, and OSPOS services
- Matrix tests default and custom DB_PASS scenarios
- Uploads install logs as artifacts
2026-05-19 08:57:32 +02:00
Ollama
4f7c4cf0f6 Add interactive SSL configuration prompt
- Prompts user for SSL preferences during installation
- Asks for domain name and email interactively
- Falls back to environment variables for non-interactive mode
- Shows SSL status in final output (Let's Encrypt / self-signed / none)
- Updates INSTALL.md with interactive/non-interactive examples

Interactive mode (recommended):
  curl -sSL https://opensourcepos.org/install | sudo bash
  # Prompts for SSL, domain, and email

Non-interactive mode:
  curl -sSL https://opensourcepos.org/install | \
    APACHE_SERVER_NAME=pos.example.com \
    SSL_EMAIL=admin@example.com \
    sudo -E bash
2026-05-19 08:57:32 +02:00
Ollama
8282066ed6 Add automatic SSL/TLS certificate setup
- Adds Let's Encrypt support for production (with auto-renewal via certbot.timer)
- Falls back to self-signed certificate for development/testing
- New SSL_EMAIL environment variable enables production SSL
- HTTPS redirect automatically configured for all sites
- Updates INSTALL.md with SSL documentation and examples

Production usage:
  SSL_EMAIL=admin@example.com APACHE_SERVER_NAME=pos.example.com

Development usage (self-signed cert):
  APACHE_SERVER_NAME=localhost (default)
2026-05-19 08:57:32 +02:00
Ollama
6b69959f7d Download latest stable release instead of master branch
- Fetches latest release version from GitHub API
- Downloads pre-built release zip instead of cloning repo
- Renamed OSPOS_BRANCH to OSPOS_VERSION for clarity
- Supports installing specific version via OSPOS_VERSION
- Removed need for composer install (release is pre-built)
- More stable for production deployments
2026-05-19 08:57:32 +02:00
Ollama
b13e4d2ce9 Update Cloud Install section to recommend one-line installer
- Keep DigitalOcean referral link ($100 credit)
- Simplify instructions to 3 steps: create droplet, SSH, run installer
- Move one-line installation section into Cloud Install
- Add security reminder to change password and configure SSL
- Retain link to wiki for manual installation options
2026-05-19 08:57:32 +02:00
jekkos
641b05f900 Update INSTALL.md with opensourcepos.org short URL
- Preferred install URL: https://opensourcepos.org/install
- Falls back to direct GitHub URL if redirect unavailable
- More professional and easier to remember
2026-05-19 08:57:32 +02:00
jekkos
1db7d6f552 Add one-line Ubuntu installation script
- Creates scripts/install-ubuntu.sh for automated fresh Ubuntu server setup
- Installs Apache, MariaDB, PHP 8.2 with required extensions
- Downloads and configures OSPOS from GitHub
- Sets up Apache virtual host with proper permissions
- Generates secure random database password
- Supports environment variables for customization
- Updates INSTALL.md with curl pipe to bash instructions

This provides an alternative to cloud-specific instructions and
allows users to quickly set up OSPOS on any fresh Ubuntu server.
2026-05-19 08:57:32 +02: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
85 changed files with 3818 additions and 1880 deletions

View File

@@ -16,6 +16,9 @@ CI_ENVIRONMENT = production
# 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'
#

94
.github/scripts/get-version.sh vendored Normal file
View File

@@ -0,0 +1,94 @@
#!/bin/bash
set -euo pipefail
# Shared version tag generation script for GitHub Actions workflows
# Usage: ./get-version.sh [FORMAT] [SHA_LENGTH]
#
# Formats:
# docker-tag - Docker image tag (default)
# archive - Archive filename suffix
# all - Output all version variables for GITHUB_OUTPUT
#
# Environment variables:
# GITHUB_REF - Git ref (e.g., refs/heads/master, refs/pull/123/merge)
# GITHUB_SHA - Git commit SHA
# GITHUB_EVENT_NAME - Event that triggered workflow (push, pull_request, etc.)
# GITHUB_EVENT_PATH - Path to event JSON (for PR number extraction)
# GITHUB_OUTPUT - Path to GITHUB_OUTPUT file (when format=all)
# Ensure we're in a git repository with source files
cd "${GITHUB_WORKSPACE:-.}"
# Get version from App.php
VERSION=$(grep "application_version" app/Config/App.php | sed "s/.*= '\(.*\)';/\1/g")
# Standardize SHA length (default: 7 chars)
SHA_LENGTH="${2:-7}"
SHA="${GITHUB_SHA:0:$SHA_LENGTH}"
# Initialize variables
IMAGE_TAG=""
BRANCH=""
# Detect event type and generate appropriate tag
if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "pull_request_review" ]]; then
# Extract PR number from event JSON
if [[ -f "${GITHUB_EVENT_PATH:-}" ]]; then
PR_NUMBER=$(jq -r '.pull_request.number // .number // empty' < "$GITHUB_EVENT_PATH" 2>/dev/null || true)
if [[ -n "$PR_NUMBER" ]]; then
# PR-based tag (for PR deployments)
IMAGE_TAG="pr-${PR_NUMBER}-${SHA}"
BRANCH="pr-${PR_NUMBER}"
fi
fi
# Fallback if we couldn't extract PR number
if [[ -z "$IMAGE_TAG" ]]; then
# Try to extract from GITHUB_REF
PR_NUMBER=$(echo "$GITHUB_REF" | grep -oP 'pull/\K[0-9]+' || true)
if [[ -n "$PR_NUMBER" ]]; then
IMAGE_TAG="pr-${PR_NUMBER}-${SHA}"
BRANCH="pr-${PR_NUMBER}"
else
# Last resort: use SHA only
IMAGE_TAG="${SHA}"
BRANCH="unknown"
fi
fi
else
# Branch-based tag (for push events)
BRANCH="${GITHUB_REF#refs/heads/}"
BRANCH=$(echo "$BRANCH" | sed 's/feature\///' | tr '/' '_')
if [[ "$BRANCH" == "master" ]]; then
# Master builds: use version as tag
IMAGE_TAG="${VERSION}"
else
# Feature branch builds: version + branch + sha
IMAGE_TAG="${VERSION}-${BRANCH}-${SHA}"
fi
fi
# Output format based on first argument
case "${1:-docker-tag}" in
docker-tag)
echo "$IMAGE_TAG"
;;
archive)
echo "${VERSION}.${SHA}"
;;
all)
{
echo "version=${VERSION}"
echo "version-tag=${IMAGE_TAG}"
echo "short-sha=${SHA}"
echo "branch=${BRANCH}"
} >> "$GITHUB_OUTPUT"
echo "::debug::version=${VERSION}, version-tag=${IMAGE_TAG}, short-sha=${SHA}, branch=${BRANCH}"
;;
*)
echo "::error::Unknown format: $1" >&2
exit 1
;;
esac

View File

@@ -22,6 +22,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
version-tag: ${{ steps.version.outputs.version-tag }}
short-sha: ${{ steps.version.outputs.short-sha }}
branch: ${{ steps.version.outputs.branch }}
steps:
- name: Checkout
@@ -75,16 +76,13 @@ jobs:
- 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
chmod +x .github/scripts/get-version.sh
.github/scripts/get-version.sh all 7
env:
GITHUB_TAG: ${{ github.ref_name }}
GITHUB_EVENT_PATH: ${{ github.event_path }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF: ${{ github.ref }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
- name: Create .env file
run: |
@@ -123,13 +121,14 @@ jobs:
.
!.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'
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Download build context
@@ -153,14 +152,14 @@ jobs:
- 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
TAG="${{ needs.build.outputs.version-tag }}"
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${TAG}" >> $GITHUB_OUTPUT
elif [ "${{ needs.build.outputs.branch }}" = "master" ]; then
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${TAG},${{ secrets.DOCKER_USERNAME }}/opensourcepos:latest" >> $GITHUB_OUTPUT
else
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${{ needs.build.outputs.version-tag }}" >> $GITHUB_OUTPUT
echo "tags=${{ secrets.DOCKER_USERNAME }}/opensourcepos:${TAG}" >> $GITHUB_OUTPUT
fi
env:
GITHUB_REF: ${{ github.ref }}
- name: Build and push Docker images
uses: docker/build-push-action@v5
@@ -193,7 +192,7 @@ jobs:
id: version
run: |
VERSION="${{ needs.build.outputs.version }}"
SHORT_SHA=$(git rev-parse --short=6 HEAD)
SHORT_SHA="${{ needs.build.outputs.short-sha }}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "short-sha=$SHORT_SHA" >> $GITHUB_OUTPUT

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

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

@@ -0,0 +1,82 @@
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
run: |
chmod +x .github/scripts/get-version.sh
IMAGE_TAG=$(.github/scripts/get-version.sh docker-tag 7)
echo "tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
env:
GITHUB_EVENT_PATH: ${{ github.event_path }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
GITHUB_REF: ${{ github.ref }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
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

@@ -0,0 +1,131 @@
name: Install Script Test
on:
push:
paths:
- 'scripts/install-ubuntu.sh'
- '.github/workflows/install-script-test.yml'
pull_request:
paths:
- 'scripts/install-ubuntu.sh'
- '.github/workflows/install-script-test.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
install-test:
name: Test Install Script (${{ matrix.scenario }})
runs-on: ubuntu-22.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- scenario: default
db_pass: ''
- scenario: custom-password
db_pass: 'TestPass123!'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Make install script executable
run: chmod +x scripts/install-ubuntu.sh
- name: Run install script
env:
DB_PASS: ${{ matrix.db_pass }}
run: |
set -o pipefail
echo "Running install script with scenario: ${{ matrix.scenario }}"
sudo -E bash scripts/install-ubuntu.sh 2>&1 | tee install-output.log
echo "Install completed successfully"
- name: Wait for services to stabilize
run: sleep 10
- name: Verify Apache is running
run: |
echo "Checking Apache status..."
sudo systemctl status apache2 --no-pager
sudo systemctl is-active apache2
- name: Verify MariaDB is running
run: |
echo "Checking MariaDB status..."
sudo systemctl status mariadb --no-pager
sudo systemctl is-active mariadb
- name: Verify Apache HTTP response
run: |
echo "Testing HTTP response on port 80..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/)
echo "HTTP Response Code: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then
echo "Apache is responding correctly"
elif [ "$HTTP_CODE" = "500" ]; then
echo "HTTP 500 - Application error. Checking .env configuration..."
sudo cat /var/www/ospos/.env 2>/dev/null | grep -E "database\.default\.(hostname|database|username|password)|encryption\.key|CI_ENVIRONMENT" | head -10
sudo cat /var/www/ospos/writable/logs/*.log 2>/dev/null | tail -20 || true
curl -s http://localhost/ | head -50
exit 1
else
echo "Unexpected HTTP code: $HTTP_CODE"
exit 1
fi
- name: Verify OSPOS login page
run: |
echo "Checking OSPOS login page..."
curl -s http://localhost/ | grep -qi "login\|password\|username" && echo "Login page content found" || {
echo "Login page verification failed"
curl -s http://localhost/ | head -50
exit 1
}
- name: Verify database exists
env:
DB_PASS: ${{ matrix.db_pass != '' && matrix.db_pass || '' }}
run: |
echo "Verifying database..."
# Extract the generated password from install output if using default
if [ -z "${{ matrix.db_pass }}" ]; then
GENERATED_PASS=$(grep -oP 'Database Password: \K[^\s]+' install-output.log || true)
if [ -n "$GENERATED_PASS" ]; then
DB_PASS="$GENERATED_PASS"
fi
fi
# Check database exists
sudo mysql -u root -e "SHOW DATABASES LIKE 'ospos';" | grep -q ospos && echo "Database 'ospos' exists" || {
echo "Database 'ospos' not found"
sudo mysql -u root -e "SHOW DATABASES;"
exit 1
}
# Check tables exist
TABLE_COUNT=$(sudo mysql -u root ospos -e "SHOW TABLES;" | wc -l)
echo "Found $TABLE_COUNT tables in database"
if [ "$TABLE_COUNT" -gt 5 ]; then
echo "Database tables verified"
else
echo "Not enough tables found"
exit 1
fi
- name: Upload install log
uses: actions/upload-artifact@v4
if: always()
with:
name: install-log-${{ matrix.scenario }}
path: install-output.log
retention-days: 7

View File

@@ -1,5 +1,4 @@
[unreleased]: https://github.com/opensourcepos/opensourcepos/compare/3.4.0...HEAD
[3.4.2]: https://github.com/opensourcepos/opensourcepos/compare/3.4.1...3.4.2
[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
@@ -34,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

@@ -13,7 +13,8 @@ RUN echo "date.timezone = \"\${PHP_TIMEZONE}\"" > /usr/local/etc/php/conf.d/time
WORKDIR /app
COPY --chown=www-data:www-data . /app
RUN chmod 770 /app/writable/uploads /app/writable/logs /app/writable/cache \
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

View File

@@ -102,5 +102,73 @@ Do **not** use below command on live deployments unless you want to tear everyth
## Cloud install
If you choose DigitalOcean:
[Through this link](https://m.do.co/c/ac38c262507b), you will get a [**free $100, 60-day credit**](https://m.do.co/c/ac38c262507b). [Check the wiki](https://github.com/opensourcepos/opensourcepos/wiki/Getting-Started-installations) for further instructions on how to install the necessary components.
### Recommended: DigitalOcean
Sign up through [our referral link](https://m.do.co/c/ac38c262507b) to get a [**$100, 60-day credit**](https://m.do.co/c/ac38c262507b).
1. Create an Ubuntu 20.04+ or 22.04+ droplet
2. SSH into your server: `ssh root@<your-droplet-ip>`
3. Run the one-line installer:
```bash
curl -sSL https://opensourcepos.org/install | sudo bash
```
The installer will:
- Install Apache, MariaDB, PHP 8.2 and required extensions
- Download the **latest stable release** of OSPOS from GitHub
- Create a database with secure random password
- Configure OSPOS and Apache
- **Set up SSL/TLS certificates** (interactive prompt or environment variables)
- Display login credentials after completion
**Interactive Mode (Recommended for first-time users):**
When run without environment variables, the installer will prompt you:
1. Whether to configure SSL (recommended for production)
2. Your domain name (e.g., `pos.example.com`)
3. Your email for Let's Encrypt (for production SSL)
```bash
curl -sSL https://opensourcepos.org/install | sudo bash
# Script will ask:
# - Configure SSL? (y/n)
# - Domain name: pos.example.com
# - Email for Let's Encrypt: admin@example.com
```
**Non-Interactive Mode (for automation):**
```bash
# Development (no SSL)
curl -sSL https://opensourcepos.org/install | APACHE_SERVER_NAME=localhost sudo -E bash
# Production with Let's Encrypt SSL
curl -sSL https://opensourcepos.org/install | APACHE_SERVER_NAME=pos.example.com SSL_EMAIL=admin@example.com sudo -E bash
# Custom database password
curl -sSL https://opensourcepos.org/install | DB_PASS=securepassword APACHE_SERVER_NAME=pos.example.com SSL_EMAIL=admin@example.com sudo -E bash
```
**Environment variables:**
- `DB_HOST` - Database host (default: localhost)
- `DB_NAME` - Database name (default: ospos)
- `DB_USER` - Database user (default: ospos)
- `DB_PASS` - Database password (default: auto-generated)
- `MYSQL_ROOT_PASS` - MariaDB root password (default: empty/no password)
- `OSPOS_DIR` - Installation directory (default: /var/www/ospos)
- `OSPOS_VERSION` - OSPOS version to install (default: latest stable release)
- `PHP_VERSION` - PHP version (default: 8.2)
- `APACHE_SERVER_NAME` - Server hostname (default: localhost, or set interactively)
- `SSL_EMAIL` - Email for Let's Encrypt. When set, enables production SSL with auto-renewal
- `SSL_DOMAIN` - Alternative to `APACHE_SERVER_NAME` for SSL certificate domain
> **Testing:** This installer is tested with each commit via our CI workflow. A fresh Ubuntu container is spawned, the script runs to completion, and basic sanity checks verify the installation. For production deployments, we recommend testing on a staging server first. If you encounter issues, please [open an issue](https://github.com/opensourcepos/opensourcepos/issues/new?template=bug_report.yml) with your server version and error output.
> **Note:** If the short URL is unavailable, use the direct GitHub URL:
> ```bash
> curl -sSL https://raw.githubusercontent.com/opensourcepos/opensourcepos/master/scripts/install-ubuntu.sh | sudo bash
> ```
For other cloud providers or manual installation, see the [detailed installation guide](https://github.com/opensourcepos/opensourcepos/wiki/Getting-Started-installations) in the wiki.
**Important:** Change the default password after first login!

View File

@@ -5,8 +5,9 @@
- [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
@@ -21,26 +22,116 @@ We release patches for security vulnerabilities.
## Security Advisories
The following security vulnerabilities have been published:
### High Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68434](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-wjm4-hfwg-5w5r) | CSRF leading to Admin Creation | 8.8 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-68147](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-xgr7-7pvw-fpmh) | Stored XSS in Return Policy | 8.1 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-66924](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-gv8j-f6gq-g59m) | Stored XSS in Item Kits | 7.2 | 2026-03-04 | 3.4.2 | @hungnqdz, @omkaryepre |
### Medium Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68658](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-32r8-8r9r-9chw) | Stored XSS in Company Name | 4.3 | 2026-01-13 | 3.4.2 | @hungnqdz |
For a complete list including draft advisories, see our [GitHub Security Advisories page](https://github.com/opensourcepos/opensourcepos/security/advisories).
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 **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**.
**Option 1: GitHub Security Advisory (Preferred)**
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.
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

@@ -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>
@@ -286,7 +286,11 @@ class App extends BaseConfig
// Solution for CodeIgniter 4 limitation: arrays cannot be set from .env
// See: https://github.com/codeigniter4/CodeIgniter4/issues/7311
$envAllowedHostnames = getenv('app.allowedHostnames');
// 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)),
@@ -327,7 +331,7 @@ class App extends BaseConfig
$errorMessage =
'Security: allowedHostnames is not configured. ' .
'Host header injection protection is disabled. ' .
'Set app.allowedHostnames in your .env file. ' .
'Set app.allowedHostnames in your .env file or ALLOWED_HOSTNAMES environment variable. ' .
'Example: app.allowedHostnames = "example.com,www.example.com" ' .
'Received Host: ' . $httpHost;

View File

@@ -486,10 +486,9 @@ class Mimes
/**
* Attempts to determine the best mime type for the given file extension.
*
* @param string $extension
* @return array|string|null The mime type found, or none if unable to determine.
* @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), '. ');
@@ -507,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), '. ');
@@ -523,7 +522,7 @@ class Mimes
}
// Reverse check the mime type list if no extension was proposed.
// This search is order-sensitive!
// This search is order sensitive!
foreach (static::$mimes as $ext => $types) {
if (in_array($type, (array) $types, true)) {
return $ext;

View File

@@ -5,7 +5,6 @@ namespace Config;
use App\Models\Appconfig;
use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Database\Exceptions\DatabaseException;
/**
* This class holds the configuration options stored from the database so that on launch those settings can be cached
@@ -41,13 +40,16 @@ class OSPOS extends BaseConfig
$this->settings[$app_config->key] = $app_config->value;
}
$this->cache->save('settings', encode_array($this->settings));
} catch (DatabaseException $e) {
} catch (\Exception $e) {
// Database table doesn't exist yet (migrations haven't run)
// Return empty settings to allow migration page to display
// or database connection failed. Return empty settings to
// allow migration page to display. Catches mysqli_sql_exception
// which is not a subclass of DatabaseException.
$this->settings = [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home'
'company' => 'Home',
'barcode_type' => 'Code39'
];
}
}

View File

@@ -3,7 +3,6 @@
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Session\Handlers\BaseHandler;
use CodeIgniter\Session\Handlers\DatabaseHandler;
use CodeIgniter\Session\Handlers\FileHandler;
@@ -139,7 +138,11 @@ class Session extends BaseConfig
$this->driver = FileHandler::class;
$this->savePath = WRITEPATH . 'session';
}
} catch (DatabaseException $e) {
} 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

@@ -246,7 +246,7 @@ class Attributes extends Secure_Controller
$data['definition_group'][''] = lang('Common.none_selected_text');
$data['definition_info'] = $info;
$show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES | Attribute::SHOW_IN_SEARCH | Attribute::SHOW_IN_CUSTOMERS | Attribute::SHOW_IN_EMPLOYEES | Attribute::SHOW_IN_SUPPLIERS;
$show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES;
$data['definition_flags'] = $this->get_attributes($show_all);
$selected_flags = $info->definition_flags === '' ? $show_all : $info->definition_flags;
$data['selected_definition_flags'] = $this->get_attributes($selected_flags);

View File

@@ -28,12 +28,9 @@ abstract class BaseController extends Controller
// protected $session;
/**
* @param RequestInterface $request
* @param ResponseInterface $response
* @param LoggerInterface $logger
* @return void
*/
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger): void
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
// 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.

View File

@@ -82,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);
@@ -221,6 +221,7 @@ class Config extends Secure_Controller
*/
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();
@@ -231,6 +232,8 @@ class Config extends Secure_Controller
$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();
@@ -921,7 +924,9 @@ class Config extends Secure_Controller
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'),
@@ -946,6 +951,44 @@ class Config extends Secure_Controller
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.
*

View File

@@ -3,7 +3,7 @@
namespace App\Controllers;
use App\Libraries\Mailchimp_lib;
use App\Models\Attribute;
use App\Models\Customer;
use App\Models\Customer_rewards;
use App\Models\Tax_code;
@@ -15,31 +15,34 @@ use stdClass;
class Customers extends Persons
{
private string $listId;
private Mailchimp_lib $mailchimpLib;
private Customer_rewards $customerRewards;
private string $_list_id;
private Mailchimp_lib $mailchimp_lib;
private Customer_rewards $customer_rewards;
private Customer $customer;
private Tax_code $taxCode;
private array $appConfig;
private Tax_code $tax_code;
private array $config;
public function __construct()
{
parent::__construct('customers');
$this->mailchimpLib = new Mailchimp_lib();
$this->customerRewards = model(Customer_rewards::class);
$this->mailchimp_lib = new Mailchimp_lib();
$this->customer_rewards = model(Customer_rewards::class);
$this->customer = model(Customer::class);
$this->taxCode = model(Tax_code::class);
$this->appConfig = config(OSPOS::class)->settings;
$this->tax_code = model(Tax_code::class);
$this->config = config(OSPOS::class)->settings;
$encrypter = Services::encrypter();
if (!empty($this->appConfig['mailchimp_list_id'])) {
$this->listId = $encrypter->decrypt($this->appConfig['mailchimp_list_id']);
if (!empty($this->config['mailchimp_list_id'])) {
$this->_list_id = $encrypter->decrypt($this->config['mailchimp_list_id']);
} else {
$this->listId = '';
$this->_list_id = '';
}
}
/**
* @return string
*/
public function getIndex(): string
{
$data['table_headers'] = get_customer_manage_table_headers();
@@ -47,13 +50,19 @@ class Customers extends Persons
return view('people/manage', $data);
}
public function getRow(int $rowId): ResponseInterface
/**
* 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): ResponseInterface
{
$person = $this->customer->get_info($rowId);
$person = $this->customer->get_info($row_id);
$stats = $this->customer->get_stats($person->person_id);
// Retrieve the total amount the customer spent so far together with min, max and average values
$stats = $this->customer->get_stats($person->person_id); // TODO: This and the next 11 lines are duplicated in search(). Extract a method.
if (empty($stats)) {
// Create object with empty properties.
$stats = new stdClass();
$stats->total = 0;
$stats->min = 0;
@@ -63,11 +72,17 @@ class Customers extends Persons
$stats->quantity = 0;
}
$dataRow = get_customer_data_row($person, $stats);
$data_row = get_customer_data_row($person, $stats);
return $this->response->setJSON($dataRow);
return $this->response->setJSON($data_row);
}
/**
* Returns customer table data rows. This will be called with AJAX.
*
* @return void
*/
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
@@ -77,13 +92,15 @@ class Customers extends Persons
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$customers = $this->customer->search($search, $limit, $offset, $sort, $order);
$totalRows = $this->customer->get_found_rows($search);
$total_rows = $this->customer->get_found_rows($search);
$dataRows = [];
$data_rows = [];
foreach ($customers->getResult() as $person) {
$stats = $this->customer->get_stats($person->person_id);
// Retrieve the total amount the customer spent so far together with min, max and average values
$stats = $this->customer->get_stats($person->person_id); // TODO: duplicated... see above
if (empty($stats)) {
// Create object with empty properties.
$stats = new stdClass();
$stats->total = 0;
$stats->min = 0;
@@ -93,12 +110,16 @@ class Customers extends Persons
$stats->quantity = 0;
}
$dataRows[] = get_customer_data_row($person, $stats);
$data_rows[] = get_customer_data_row($person, $stats);
}
return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows]);
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(): ResponseInterface
{
$search = $this->request->getGet('term');
@@ -107,7 +128,10 @@ class Customers extends Persons
return $this->response->setJSON($suggestions);
}
public function suggestSearch(): ResponseInterface
/**
* @return ResponseInterface
*/
public function suggest_search(): ResponseInterface
{
$search = $this->request->getGet('term');
$suggestions = $this->customer->get_search_suggestions($search, 25, false);
@@ -115,11 +139,16 @@ class Customers extends Persons
return $this->response->setJSON($suggestions);
}
public function getView(int $customerId = NEW_ENTRY): string
/**
* Loads the customer edit form
* @return string
*/
public function getView(int $customer_id = NEW_ENTRY): string
{
if ($customerId == null) $customerId = NEW_ENTRY;
// Set default values
if ($customer_id == null) $customer_id = NEW_ENTRY;
$info = $this->customer->get_info($customerId);
$info = $this->customer->get_info($customer_id);
foreach (get_object_vars($info) as $property => $value) {
$info->$property = $value;
}
@@ -130,27 +159,28 @@ class Customers extends Persons
$data['person_info']->employee_id = $this->employee->get_logged_in_employee_info()->person_id;
}
$employeeInfo = $this->employee->get_info($info->employee_id);
$data['employee'] = $employeeInfo->first_name . ' ' . $employeeInfo->last_name;
$employee_info = $this->employee->get_info($info->employee_id);
$data['employee'] = $employee_info->first_name . ' ' . $employee_info->last_name;
$taxCodeInfo = $this->taxCode->get_info($info->sales_tax_code_id);
$tax_code_info = $this->tax_code->get_info($info->sales_tax_code_id);
if ($taxCodeInfo->tax_code != null) {
$data['sales_tax_code_label'] = $taxCodeInfo->tax_code . ' ' . $taxCodeInfo->tax_code_name;
if ($tax_code_info->tax_code != null) {
$data['sales_tax_code_label'] = $tax_code_info->tax_code . ' ' . $tax_code_info->tax_code_name;
} else {
$data['sales_tax_code_label'] = '';
}
$packages = ['' => lang('Items.none')];
foreach ($this->customerRewards->get_all()->getResultArray() as $row) {
foreach ($this->customer_rewards->get_all()->getResultArray() as $row) {
$packages[$row['package_id']] = $row['package_name'];
}
$data['packages'] = $packages;
$data['selected_package'] = $info->package_id;
$data['use_destination_based_tax'] = $this->appConfig['use_destination_based_tax'];
$data['use_destination_based_tax'] = $this->config['use_destination_based_tax'];
$stats = $this->customer->get_stats($customerId);
// Retrieve the total amount the customer spent so far together with min, max and average values
$stats = $this->customer->get_stats($customer_id);
if (!empty($stats)) {
foreach (get_object_vars($stats) as $property => $value) {
$info->$property = $value;
@@ -158,11 +188,14 @@ class Customers extends Persons
$data['stats'] = $stats;
}
// Retrieve the info from Mailchimp only if there is an email address assigned
if (!empty($info->email)) {
if (($mailchimpInfo = $this->mailchimpLib->getMemberInfo($this->listId, $info->email)) !== false) {
$data['mailchimp_info'] = $mailchimpInfo;
// Collect Mailchimp customer info
if (($mailchimp_info = $this->mailchimp_lib->getMemberInfo($this->_list_id, $info->email)) !== false) {
$data['mailchimp_info'] = $mailchimp_info;
if (($activities = $this->mailchimpLib->getMemberActivity($this->listId, $info->email)) !== false) {
// Collect customer Mailchimp emails activities (stats)
if (($activities = $this->mailchimp_lib->getMemberActivity($this->_list_id, $info->email)) !== false) {
if (array_key_exists('activity', $activities)) {
$open = 0;
$unopen = 0;
@@ -202,25 +235,22 @@ class Customers extends Persons
}
/**
* Gets person attributes for a customer (AJAX)
* Inserts/updates a customer
* @return ResponseInterface
*/
public function getAttributes(int $customerId = NEW_ENTRY): string
public function postSave(int $customer_id = NEW_ENTRY): ResponseInterface
{
return $this->getPersonAttributes($customerId, Attribute::SHOW_IN_CUSTOMERS);
}
public function postSave(int $customerId = NEW_ENTRY): ResponseInterface
{
$firstName = $this->request->getPost('first_name');
$lastName = $this->request->getPost('last_name');
$first_name = $this->request->getPost('first_name');
$last_name = $this->request->getPost('last_name');
$email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL));
$firstName = $this->nameize($firstName);
$lastName = $this->nameize($lastName);
// Format first and last name properly
$first_name = $this->nameize($first_name);
$last_name = $this->nameize($last_name);
$personData = [
'first_name' => $firstName,
'last_name' => $lastName,
$person_data = [
'first_name' => $first_name,
'last_name' => $last_name,
'gender' => $this->request->getPost('gender', FILTER_SANITIZE_NUMBER_INT),
'email' => $email,
'phone_number' => $this->request->getPost('phone_number'),
@@ -233,9 +263,9 @@ class Customers extends Persons
'comments' => $this->request->getPost('comments')
];
$dateFormatter = date_create_from_format($this->appConfig['dateformat'] . ' ' . $this->appConfig['timeformat'], $this->request->getPost('date'));
$date_formatter = date_create_from_format($this->config['dateformat'] . ' ' . $this->config['timeformat'], $this->request->getPost('date'));
$customerData = [
$customer_data = [
'consent' => $this->request->getPost('consent') != null,
'account_number' => $this->request->getPost('account_number') == '' ? null : $this->request->getPost('account_number'),
'tax_id' => $this->request->getPost('tax_id'),
@@ -244,57 +274,68 @@ class Customers extends Persons
'discount_type' => $this->request->getPost('discount_type') == null ? PERCENT : $this->request->getPost('discount_type', FILTER_SANITIZE_NUMBER_INT),
'package_id' => $this->request->getPost('package_id') == '' ? null : $this->request->getPost('package_id'),
'taxable' => $this->request->getPost('taxable') != null,
'date' => $dateFormatter->format('Y-m-d H:i:s'),
'date' => $date_formatter->format('Y-m-d H:i:s'),
'employee_id' => $this->request->getPost('employee_id', FILTER_SANITIZE_NUMBER_INT),
'sales_tax_code_id' => $this->request->getPost('sales_tax_code_id') == '' ? null : $this->request->getPost('sales_tax_code_id', FILTER_SANITIZE_NUMBER_INT)
];
if ($this->customer->save_customer($personData, $customerData, $customerId)) {
$personId = $customerId == NEW_ENTRY ? $customerData['person_id'] : $customerId;
$this->savePersonAttributes($personId, Attribute::SHOW_IN_CUSTOMERS);
$mailchimpStatus = $this->request->getPost('mailchimp_status');
$this->mailchimpLib->addOrUpdateMember(
$this->listId,
if ($this->customer->save_customer($person_data, $customer_data, $customer_id)) {
// Save customer to Mailchimp selected list // TODO: addOrUpdateMember should be refactored. Potentially pass an array or object instead of 6 parameters.
$mailchimp_status = $this->request->getPost('mailchimp_status');
$this->mailchimp_lib->addOrUpdateMember(
$this->_list_id,
$email,
$firstName,
$lastName,
$mailchimpStatus == null ? "" : $mailchimpStatus,
$first_name,
$last_name,
$mailchimp_status == null ? "" : $mailchimp_status,
['vip' => $this->request->getPost('mailchimp_vip') != null]
);
if ($customerId == NEW_ENTRY) {
// New customer
if ($customer_id == NEW_ENTRY) {
return $this->response->setJSON([
'success' => true,
'message' => lang('Customers.successful_adding') . ' ' . $firstName . ' ' . $lastName,
'id' => $customerData['person_id']
'message' => lang('Customers.successful_adding') . ' ' . $first_name . ' ' . $last_name,
'id' => $customer_data['person_id']
]);
} else {
} else { // Existing customer
return $this->response->setJSON([
'success' => true,
'message' => lang('Customers.successful_updating') . ' ' . $firstName . ' ' . $lastName,
'id' => $customerId
'message' => lang('Customers.successful_updating') . ' ' . $first_name . ' ' . $last_name,
'id' => $customer_id
]);
}
} else {
} else { // Failure
return $this->response->setJSON([
'success' => false,
'message' => lang('Customers.error_adding_updating') . ' ' . $firstName . ' ' . $lastName,
'message' => lang('Customers.error_adding_updating') . ' ' . $first_name . ' ' . $last_name,
'id' => NEW_ENTRY
]);
}
}
/**
* Verifies if an email address already exists. Used in app/Views/customers/form.php
*
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postCheckEmail(): ResponseInterface
{
$email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL));
$personId = $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT);
$person_id = $this->request->getPost('person_id', FILTER_SANITIZE_NUMBER_INT);
$exists = $this->customer->check_email_exists($email, $personId);
$exists = $this->customer->check_email_exists($email, $person_id);
return $this->response->setJSON(!$exists ? 'true' : 'false');
}
/**
* Verifies if an account number already exists. Used in app/Views/customers/form.php
*
* @return ResponseInterface
* @noinspection PhpUnused
*/
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));
@@ -302,22 +343,27 @@ class Customers extends Persons
return $this->response->setJSON(!$exists ? 'true' : 'false');
}
/**
* This deletes customers from the customers table
* @return ResponseInterface
*/
public function postDelete(): ResponseInterface
{
$customersToDelete = $this->request->getPost('ids');
$customersInfo = $this->customer->get_multiple_info($customersToDelete);
$customers_to_delete = $this->request->getPost('ids');
$customers_info = $this->customer->get_multiple_info($customers_to_delete);
$count = 0;
foreach ($customersInfo->getResult() as $info) {
foreach ($customers_info->getResult() as $info) {
if ($this->customer->delete($info->person_id)) {
$this->mailchimpLib->removeMember($this->listId, $info->email);
// remove customer from Mailchimp selected list
$this->mailchimp_lib->removeMember($this->_list_id, $info->email);
$count++;
}
}
if ($count == count($customersToDelete)) {
if ($count == count($customers_to_delete)) {
return $this->response->setJSON([
'success' => true,
'message' => lang('Customers.successful_deleted') . ' ' . $count . ' ' . lang('Customers.one_or_multiple')
@@ -327,6 +373,12 @@ class Customers extends Persons
}
}
/**
* Customers import from csv spreadsheet
*
* @return DownloadResponse The template for Customer CSV imports is returned and download forced.
* @noinspection PhpUnused
*/
public function getCsv(): DownloadResponse
{
$name = 'importCustomers.csv';
@@ -334,17 +386,30 @@ class Customers extends Persons
return $this->response->download($name, $data);
}
/**
* Displays the customer CSV import modal. Used in app/Views/people/manage.php
*
* @return string
* @noinspection PhpUnused
*/
public function getCsvImport(): string
{
return view('customers/form_csv_import');
}
/**
* Imports a CSV file containing customers. Used in app/Views/customers/form_csv_import.php
*
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function postImportCsvFile(): ResponseInterface
{
if ($_FILES['file_path']['error'] != UPLOAD_ERR_OK) {
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
fgetcsv($handle);
$i = 1;
@@ -355,7 +420,7 @@ class Customers extends Persons
if (sizeof($data) >= 16 && $consent) {
$email = strtolower($data[4]);
$personData = [
$person_data = [
'first_name' => $data[0],
'last_name' => $data[1],
'gender' => $data[2],
@@ -370,7 +435,7 @@ class Customers extends Persons
'comments' => $data[12]
];
$customerData = [
$customer_data = [
'consent' => $consent,
'company_name' => $data[13],
'discount' => $data[15],
@@ -379,13 +444,14 @@ class Customers extends Persons
'date' => date('Y-m-d H:i:s'),
'employee_id' => $this->employee->get_logged_in_employee_info()->person_id
];
$accountNumber = $data[14];
$account_number = $data[14];
// Don't duplicate people with same email
$invalidated = $this->customer->check_email_exists($email);
if ($accountNumber != '') {
$customerData['account_number'] = $accountNumber;
$invalidated &= $this->customer->check_account_number_exists($accountNumber);
if ($account_number != '') {
$customer_data['account_number'] = $account_number;
$invalidated &= $this->customer->check_account_number_exists($account_number);
}
} else {
$invalidated = true;
@@ -394,8 +460,9 @@ class Customers extends Persons
if ($invalidated) {
$failCodes[] = $i;
log_message('error', "Row $i was not imported: Either email or account number already exist or data was invalid.");
} elseif ($this->customer->save_customer($personData, $customerData)) {
$this->mailchimpLib->addOrUpdateMember($this->listId, $personData['email'], $personData['first_name'], '', $personData['last_name']);
} elseif ($this->customer->save_customer($person_data, $customer_data)) {
// Save customer to Mailchimp selected list
$this->mailchimp_lib->addOrUpdateMember($this->_list_id, $person_data['email'], $person_data['first_name'], '', $person_data['last_name']);
} else {
$failCodes[] = $i;
}
@@ -415,4 +482,4 @@ class Customers extends Persons
}
}
}
}
}

View File

@@ -2,15 +2,18 @@
namespace App\Controllers;
use App\Models\Attribute;
use App\Models\Module;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
/**
*
*
* @property module module
*
*/
class Employees extends Persons
{
protected Module $module;
public function __construct()
{
parent::__construct('employees');
@@ -18,25 +21,35 @@ class Employees extends Persons
$this->module = model('Module');
}
/**
* Returns employee table data rows. This will be called with AJAX.
*
* @return void
*/
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$sort = $this->sanitizeSortColumn(person_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'people.person_id');
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$sort = $this->sanitizeSortColumn(person_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'people.person_id');
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$employees = $this->employee->search($search, $limit, $offset, $sort, $order);
$totalRows = $this->employee->get_found_rows($search);
$total_rows = $this->employee->get_found_rows($search);
$dataRows = [];
$data_rows = [];
foreach ($employees->getResult() as $person) {
$dataRows[] = get_person_data_row($person);
$data_rows[] = get_person_data_row($person);
}
return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows]);
return $this->response->setJSON(['total' => $total_rows, 'rows' => $data_rows]);
}
/**
* AJAX called function gives search suggestions based on what is being searched for.
*
* @return ResponseInterface
*/
public function getSuggest(): ResponseInterface
{
$search = $this->request->getGet('term');
@@ -45,7 +58,10 @@ class Employees extends Persons
return $this->response->setJSON($suggestions);
}
public function suggestSearch(): ResponseInterface
/**
* @return ResponseInterface
*/
public function suggest_search(): ResponseInterface
{
$search = $this->request->getPost('term');
$suggestions = $this->employee->get_search_suggestions($search);
@@ -53,35 +69,39 @@ class Employees extends Persons
return $this->response->setJSON($suggestions);
}
public function getView(int $employeeId = NEW_ENTRY): string
/**
* Loads the employee edit form
* @return string
*/
public function getView(int $employee_id = NEW_ENTRY): string
{
$personInfo = $this->employee->get_info($employeeId);
$currentUser = $this->employee->get_logged_in_employee_info();
$person_info = $this->employee->get_info($employee_id);
$current_user = $this->employee->get_logged_in_employee_info();
if ($employeeId != NEW_ENTRY && !$this->employee->canModifyEmployee($personInfo->person_id, $currentUser->person_id)) {
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($personInfo) as $property => $value) {
$personInfo->$property = $value;
foreach (get_object_vars($person_info) as $property => $value) {
$person_info->$property = $value;
}
$data['person_info'] = $personInfo;
$data['employee_id'] = $employeeId;
$data['person_info'] = $person_info;
$data['employee_id'] = $employee_id;
$modules = [];
foreach ($this->module->get_all_modules()->getResult() as $module) {
$module->grant = $this->employee->has_grant($module->module_id, $personInfo->person_id);
$module->menu_group = $this->employee->get_menu_group($module->module_id, $personInfo->person_id);
$module->grant = $this->employee->has_grant($module->module_id, $person_info->person_id);
$module->menu_group = $this->employee->get_menu_group($module->module_id, $person_info->person_id);
$modules[] = $module;
}
$data['all_modules'] = $modules;
$permissions = [];
foreach ($this->module->get_all_subpermissions()->getResult() as $permission) {
foreach ($this->module->get_all_subpermissions()->getResult() as $permission) { // TODO: subpermissions does not follow naming standards.
$permission->permission_id = str_replace(' ', '_', $permission->permission_id);
$permission->grant = $this->employee->has_grant($permission->permission_id, $personInfo->person_id);
$permission->grant = $this->employee->has_grant($permission->permission_id, $person_info->person_id);
$permissions[] = $permission;
}
@@ -90,18 +110,17 @@ class Employees extends Persons
return view('employees/form', $data);
}
public function getAttributes(int $employeeId = NEW_ENTRY): string
/**
* Inserts/updates an employee
* @return ResponseInterface
*/
public function postSave(int $employee_id = NEW_ENTRY): ResponseInterface
{
return $this->getPersonAttributes($employeeId, Attribute::SHOW_IN_EMPLOYEES);
}
$current_user = $this->employee->get_logged_in_employee_info();
public function postSave(int $employeeId = NEW_ENTRY): ResponseInterface
{
$currentUser = $this->employee->get_logged_in_employee_info();
if ($employeeId != NEW_ENTRY) {
$targetEmployee = $this->employee->get_info($employeeId);
if (!$this->employee->canModifyEmployee($targetEmployee->person_id, $currentUser->person_id)) {
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'),
@@ -110,16 +129,17 @@ class Employees extends Persons
}
}
$firstName = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$lastName = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$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));
$firstName = $this->nameize($firstName);
$lastName = $this->nameize($lastName);
// format first and last name properly
$first_name = $this->nameize($first_name);
$last_name = $this->nameize($last_name);
$personData = [
'first_name' => $firstName,
'last_name' => $lastName,
$person_data = [
'first_name' => $first_name,
'last_name' => $last_name,
'gender' => $this->request->getPost('gender', FILTER_SANITIZE_NUMBER_INT),
'email' => $email,
'phone_number' => $this->request->getPost('phone_number', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
@@ -132,98 +152,108 @@ class Employees extends Persons
'comments' => $this->request->getPost('comments', FILTER_SANITIZE_FULL_SPECIAL_CHARS)
];
$grantsArray = [];
$isAdmin = $this->employee->isAdmin($currentUser->person_id);
$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, $currentUser->person_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) : '--';
$grantsArray[] = $grants;
$grants_array[] = $grants;
}
}
// Password has been changed OR first time password set
if (!empty($this->request->getPost('password')) && ENVIRONMENT != 'testing') {
$exploded = explode(":", $this->request->getPost('language', FILTER_SANITIZE_FULL_SPECIAL_CHARS));
$employeeData = [
$employee_data = [
'username' => $this->request->getPost('username', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
'password' => password_hash($this->request->getPost('password'), PASSWORD_DEFAULT),
'hash_version' => 2,
'language_code' => $exploded[0],
'language' => $exploded[1]
];
} else {
} else { // Password not changed
$exploded = explode(":", $this->request->getPost('language', FILTER_SANITIZE_FULL_SPECIAL_CHARS));
$employeeData = [
$employee_data = [
'username' => $this->request->getPost('username', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
'language_code' => $exploded[0],
'language' => $exploded[1]
];
}
if ($this->employee->save_employee($personData, $employeeData, $grantsArray, $employeeId)) {
$personId = $employeeId == NEW_ENTRY ? $employeeData['person_id'] : $employeeId;
$this->savePersonAttributes($personId, Attribute::SHOW_IN_EMPLOYEES);
if ($employeeId == NEW_ENTRY) {
if ($this->employee->save_employee($person_data, $employee_data, $grants_array, $employee_id)) {
// New employee
if ($employee_id == NEW_ENTRY) {
return $this->response->setJSON([
'success' => true,
'message' => lang('Employees.successful_adding') . ' ' . $firstName . ' ' . $lastName,
'id' => $employeeData['person_id']
'message' => lang('Employees.successful_adding') . ' ' . $first_name . ' ' . $last_name,
'id' => $employee_data['person_id']
]);
} else {
$loggedInEmployeeId = session()->get('person_id');
if ($employeeId == $loggedInEmployeeId) {
session()->set('language_code', $employeeData['language_code']);
session()->set('language', $employeeData['language']);
} else { // Existing employee
$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') . ' ' . $firstName . ' ' . $lastName,
'id' => $employeeId
'message' => lang('Employees.successful_updating') . ' ' . $first_name . ' ' . $last_name,
'id' => $employee_id
]);
}
} else {
} else { // Failure
return $this->response->setJSON([
'success' => false,
'message' => lang('Employees.error_adding_updating') . ' ' . $firstName . ' ' . $lastName,
'message' => lang('Employees.error_adding_updating') . ' ' . $first_name . ' ' . $last_name,
'id' => NEW_ENTRY
]);
}
}
/**
* This deletes employees from the employees table
* @return ResponseInterface
*/
public function postDelete(): ResponseInterface
{
$employeesToDelete = $this->request->getPost('ids', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$currentUser = $this->employee->get_logged_in_employee_info();
$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($currentUser->person_id)) {
foreach ($employeesToDelete as $empId) {
if ($this->employee->isAdmin((int)$empId)) {
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($employeesToDelete)) {
if ($this->employee->delete_list($employees_to_delete)) { // TODO: this is passing a string, but delete_list expects an array
return $this->response->setJSON([
'success' => true,
'message' => lang('Employees.successful_deleted') . ' ' . count($employeesToDelete) . ' ' . lang('Employees.one_or_multiple')
'message' => lang('Employees.successful_deleted') . ' ' . count($employees_to_delete) . ' ' . lang('Employees.one_or_multiple')
]);
} else {
return $this->response->setJSON(['success' => false, 'message' => lang('Employees.cannot_be_deleted')]);
}
}
public function getCheckUsername($employeeId): ResponseInterface
/**
* Checks an employee username against the database. Used in app\Views\employees\form.php
*
* @param $employee_id
* @return ResponseInterface
* @noinspection PhpUnused
*/
public function getCheckUsername($employee_id): ResponseInterface
{
$exists = $this->employee->username_exists($employeeId, $this->request->getGet('username'));
$exists = $this->employee->username_exists($employee_id, $this->request->getGet('username'));
return $this->response->setJSON(!$exists ? 'true' : 'false');
}
}
}

View File

@@ -43,7 +43,7 @@ class Home extends Secure_Controller
public function getChangePassword(int $employeeId = NEW_ENTRY): ResponseInterface|string
{
$loggedInEmployee = $this->employee->get_logged_in_employee_info();
$currentPersonId = $loggedInEmployee->person_id;
$currentPersonId = (int) $loggedInEmployee->person_id;
$employeeId = $employeeId === NEW_ENTRY ? $currentPersonId : $employeeId;
@@ -68,10 +68,11 @@ class Home extends Secure_Controller
public function postSave(int $employeeId = NEW_ENTRY): ResponseInterface
{
$currentUser = $this->employee->get_logged_in_employee_info();
$currentPersonId = (int) $currentUser->person_id;
$employeeId = $employeeId === NEW_ENTRY ? $currentUser->person_id : $employeeId;
$employeeId = $employeeId === NEW_ENTRY ? $currentPersonId : $employeeId;
if (!$this->employee->isAdmin($currentUser->person_id) && $employeeId !== $currentUser->person_id) {
if (!$this->employee->isAdmin($currentPersonId) && $employeeId !== $currentPersonId) {
return $this->response->setStatusCode(403)->setJSON([
'success' => false,
'message' => lang('Employees.unauthorized_modify')

View File

@@ -154,8 +154,23 @@ class Items extends Secure_Controller
{
helper('file');
$pic_filename = rawurldecode($pic_filename);
$file_extension = pathinfo($pic_filename, PATHINFO_EXTENSION);
// Security: Sanitize filename to prevent path traversal
// Use basename() to strip directory components and prevent '../' attacks
$pic_filename = basename(rawurldecode($pic_filename));
$file_extension = strtolower(pathinfo($pic_filename, PATHINFO_EXTENSION));
// Validate file extension against system-configured allowed image types
// Handle both legacy pipe-separated and current comma-separated formats
// Fallback to types that GD library can process for thumbnail generation
$allowed_types = $this->config['image_allowed_types'] ?? 'jpg,jpeg,gif,png,webp,bmp,tif,tiff';
$allowed_extensions = strpos($allowed_types, '|') !== false
? explode('|', $allowed_types)
: explode(',', $allowed_types);
if (!in_array($file_extension, $allowed_extensions, true)) {
return $this->response->setStatusCode(400)->setBody('Invalid file type');
}
$images = glob("./uploads/item_pics/$pic_filename");
$base_path = './uploads/item_pics/' . pathinfo($pic_filename, PATHINFO_FILENAME);
@@ -1040,14 +1055,20 @@ class Items extends Secure_Controller
});
if (!$isFailedRow && $this->item->save_value($itemData, $itemId)) {
$this->save_tax_data($row, $itemData);
$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId);
if (!$this->save_tax_data($row, $itemData)) {
$isFailedRow = true;
}
if (!$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId)) {
$isFailedRow = true;
}
$csvAttributeValues = $this->extractAttributeData($row);
$isFailedRow = !$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData);
if (!$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData)) {
$isFailedRow = true;
}
if ($isFailedRow) {
$failedRow = $key + 2;
$failCodes[] = $failedRow;
log_message('error', "CSV Item import failed on line $failedRow while saving attributes.");
log_message('error', "CSV Item import failed on line $failedRow while saving item.");
continue;
}
@@ -1237,13 +1258,15 @@ class Items extends Secure_Controller
* @param array $item_data
* @param array $allowed_locations
* @param int $employee_id
* @return bool Returns true on success, false on failure
* @throws ReflectionException
*/
private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): void
private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): bool
{
// Quantities & Inventory Section
$comment = lang('Items.inventory_CSV_import_quantity');
$is_update = (bool)$row['Id'];
$success = true;
foreach ($allowed_locations as $location_id => $location_name) {
$item_quantity_data = ['item_id' => $item_data['item_id'], 'location_id' => $location_id];
@@ -1257,20 +1280,22 @@ class Items extends Secure_Controller
if (!empty($row["location_$location_name"]) || $row["location_$location_name"] === '0') {
$item_quantity_data['quantity'] = $row["location_$location_name"];
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = $row["location_$location_name"];
$this->inventory->insert($csv_data, false);
$success &= (bool)$this->inventory->insert($csv_data, false);
} elseif ($is_update) {
return;
continue;
} else {
$item_quantity_data['quantity'] = 0;
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = 0;
$this->inventory->insert($csv_data, false);
$success &= (bool)$this->inventory->insert($csv_data, false);
}
}
return (bool)$success;
}
/**
@@ -1278,8 +1303,9 @@ class Items extends Secure_Controller
*
* @param array $row
* @param array $item_data
* @return bool Returns true on success, false on failure
*/
private function save_tax_data(array $row, array $item_data): void
private function save_tax_data(array $row, array $item_data): bool
{
$items_taxes_data = [];
@@ -1291,9 +1317,11 @@ class Items extends Secure_Controller
$items_taxes_data[] = ['name' => $row['Tax 2 Name'], 'percent' => $row['Tax 2 Percent']];
}
if (isset($items_taxes_data)) {
$this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
if (!empty($items_taxes_data)) {
return $this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
}
return true;
}
/**

View File

@@ -2,28 +2,28 @@
namespace App\Controllers;
use App\Models\Attribute;
use App\Models\Person;
use CodeIgniter\HTTP\ResponseInterface;
use Config\OSPOS;
use Config\Services;
use function Tamtamchik\NameCase\str_name_case;
abstract class Persons extends Secure_Controller
{
protected Person $person;
protected Attribute $attribute;
protected array $appConfig;
public function __construct(?string $moduleId = null)
/**
* @param string|null $module_id
*/
public function __construct(?string $module_id = null)
{
parent::__construct($moduleId);
parent::__construct($module_id);
$this->person = model(Person::class);
$this->attribute = model(Attribute::class);
$this->appConfig = config(OSPOS::class)->settings;
}
/**
* @return string
*/
public function getIndex(): string
{
$data['table_headers'] = get_people_manage_table_headers();
@@ -31,6 +31,10 @@ abstract class Persons extends Secure_Controller
return view('people/manage', $data);
}
/**
* Gives search suggestions based on what is being searched for
* @return ResponseInterface
*/
public function getSuggest(): ResponseInterface
{
$search = $this->request->getGet('term');
@@ -39,88 +43,34 @@ abstract class Persons extends Secure_Controller
return $this->response->setJSON($suggestions);
}
public function getRow(int $rowId): ResponseInterface
/**
* 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): ResponseInterface
{
$dataRow = get_person_data_row($this->person->get_info($rowId));
$data_row = get_person_data_row($this->person->get_info($row_id));
return $this->response->setJSON($dataRow);
}
protected function getPersonAttributes(int $personId, int $definitionFlags): string
{
$data['person_id'] = $personId;
$data['config'] = $this->appConfig;
$definitionIds = json_decode($this->request->getGet('definition_ids') ?? '', true);
$data['definition_values'] = $this->attribute->getAttributesByPerson($personId) + $this->attribute->get_values_by_definitions($definitionIds);
$data['definition_names'] = $this->attribute->getDefinitionsByType(true, $definitionFlags);
foreach ($data['definition_values'] as $definitionId => $definitionValue) {
$attributeValue = $this->attribute->getPersonAttributeValue($personId, $definitionId);
$attributeId = (empty($attributeValue) || empty($attributeValue->attribute_id)) ? null : $attributeValue->attribute_id;
$values = &$data['definition_values'][$definitionId];
$values['attribute_id'] = $attributeId;
$values['attribute_value'] = $attributeValue;
$values['selected_value'] = '';
if ($definitionValue['definition_type'] === DROPDOWN) {
$values['values'] = $this->attribute->get_definition_values($definitionId);
$linkValue = $this->getPersonLinkValue($personId, $definitionId);
$values['selected_value'] = (empty($linkValue)) ? '' : $linkValue->attribute_id;
}
if (!empty($definitionIds[$definitionId])) {
$values['selected_value'] = $definitionIds[$definitionId];
}
unset($data['definition_names'][$definitionId]);
}
return view('attributes/person', $data);
}
private function getPersonLinkValue(int $personId, int $definitionId): ?object
{
$builder = $this->db->table('attribute_links');
$builder->where('person_id', $personId);
$builder->where('item_id', null);
$builder->where('sale_id', null);
$builder->where('receiving_id', null);
$builder->where('definition_id', $definitionId);
return $builder->get()->getRowObject();
}
protected function savePersonAttributes(int $personId, int $definitionFlags): void
{
$attributeLinks = $this->request->getPost('attribute_links') ?? [];
$attributeIds = $this->request->getPost('attribute_ids') ?? [];
$this->attribute->deletePersonAttributeLinks($personId);
foreach ($attributeLinks as $definitionId => $attributeId) {
$definitionInfo = $this->attribute->getAttributeInfo((int)$definitionId);
$definitionType = $definitionInfo->definition_type;
if ($definitionType !== DROPDOWN) {
$attributeId = $this->attribute->savePersonAttributeValue(
$attributeId,
(int)$definitionId,
$personId,
$attributeIds[$definitionId] ?? false,
$definitionType
);
}
$this->attribute->savePersonAttributeLink($personId, (int)$definitionId, (int)$attributeId);
}
return $this->response->setJSON($data_row);
}
/**
* Capitalize segments of a name, and put the rest into lower case.
* You can pass the characters you want to use as delimiters as exceptions.
* The function supports UTF-8 strings
*
* Example:
* i.e. <?php echo nameize("john o'grady-smith"); ?>
*
* returns John O'Grady-Smith
*/
protected function nameize(string $input): string
{
$adjustedName = str_name_case($input);
$adjusted_name = str_name_case($input);
// TODO: Use preg_replace to match HTML entities and convert them to lowercase. This is a workaround for https://github.com/tamtamchik/namecase/issues/20
return preg_replace_callback('/&[a-zA-Z0-9#]+;/', function ($matches) {
return strtolower($matches[0]);
}, $adjustedName);
}, $adjusted_name);
}
}
}

View File

@@ -1246,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')
];
}

View File

@@ -93,6 +93,8 @@ class Sales extends Secure_Controller
'only_check' => lang('Sales.check_filter'),
'only_creditcard' => lang('Sales.credit_filter'),
'only_debit' => lang('Sales.debit'),
'only_bank_transfer'=> lang('Sales.bank_transfer'),
'only_wallet' => lang('Sales.wallet'),
'only_invoices' => lang('Sales.invoice_filter'),
'selected_customer' => lang('Sales.selected_customer')
];
@@ -156,6 +158,8 @@ class Sales extends Secure_Controller
'selected_customer' => false,
'only_creditcard' => false,
'only_debit' => false,
'only_bank_transfer'=> false,
'only_wallet' => false,
'only_invoices' => $this->config['invoice_enable'] && $this->request->getGet('only_invoices', FILTER_SANITIZE_NUMBER_INT),
'is_valid_receipt' => $this->sale->is_valid_receipt($search)
];
@@ -904,6 +908,14 @@ class Sales extends Secure_Controller
return $this->_reload($data);
} else {
$data['barcode'] = $this->barcode_lib->generate_receipt_barcode($data['sale_id']);
// Validate receipt template to prevent path traversal
$receipt_template = $this->config['receipt_template'] ?? '';
if (!Sale_lib::isValidReceiptTemplate($receipt_template)) {
$receipt_template = 'receipt_default';
}
$data['receipt_template_view'] = $receipt_template;
$this->sale_lib->clear_all();
return view('sales/receipt', $data);
}
@@ -1159,6 +1171,13 @@ class Sales extends Secure_Controller
}
$data['invoice_view'] = $invoice_type;
// Validate receipt template to prevent path traversal
$receipt_template = $this->config['receipt_template'] ?? '';
if (!Sale_lib::isValidReceiptTemplate($receipt_template)) {
$receipt_template = 'receipt_default';
}
$data['receipt_template_view'] = $receipt_template;
return $data;
}
@@ -1253,6 +1272,7 @@ class Sales extends Secure_Controller
$data['quote_number'] = $this->sale_lib->get_quote_number();
$data['work_order_number'] = $this->sale_lib->get_work_order_number();
$data['keyboardShortcuts'] = $this->sale_lib->getKeyShortcuts();
// TODO: the if/else set below should be converted to a switch
if ($this->sale_lib->get_mode() == 'sale_invoice') { // TODO: Duplicated code.
@@ -1641,7 +1661,9 @@ class Sales extends Secure_Controller
*/
public function getSalesKeyboardHelp(): string
{
return view('sales/help');
return view('sales/help', [
'keyboardShortcuts' => $this->sale_lib->getKeyShortcuts()
]);
}
/**

View File

@@ -2,7 +2,6 @@
namespace App\Controllers;
use App\Models\Attribute;
use App\Models\Supplier;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
@@ -18,6 +17,9 @@ class Suppliers extends Persons
$this->supplier = model(Supplier::class);
}
/**
* @return string
*/
public function getIndex(): string
{
$data['table_headers'] = get_suppliers_manage_table_headers();
@@ -25,14 +27,23 @@ class Suppliers extends Persons
return view('people/manage', $data);
}
public function getRow($rowId): ResponseInterface
/**
* Gets one row for a supplier manage table. This is called using AJAX to update one row.
* @param $row_id
* @return ResponseInterface
*/
public function getRow($row_id): ResponseInterface
{
$dataRow = get_supplier_data_row($this->supplier->get_info($rowId));
$dataRow['category'] = $this->supplier->get_category_name($dataRow['category']);
$data_row = get_supplier_data_row($this->supplier->get_info($row_id));
$data_row['category'] = $this->supplier->get_category_name($data_row['category']);
return $this->response->setJSON($dataRow);
return $this->response->setJSON($data_row);
}
/**
* Returns Supplier table data rows. This will be called with AJAX.
* @return void
**/
public function getSearch(): ResponseInterface
{
$search = $this->request->getGet('search');
@@ -42,19 +53,23 @@ class Suppliers extends Persons
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$suppliers = $this->supplier->search($search, $limit, $offset, $sort, $order);
$totalRows = $this->supplier->get_found_rows($search);
$total_rows = $this->supplier->get_found_rows($search);
$dataRows = [];
$data_rows = [];
foreach ($suppliers->getResult() as $supplier) {
$row = get_supplier_data_row($supplier);
$row['category'] = $this->supplier->get_category_name($row['category']);
$dataRows[] = $row;
$data_rows[] = $row;
}
return $this->response->setJSON(['total' => $totalRows, 'rows' => $dataRows]);
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(): ResponseInterface
{
$search = $this->request->getGet('term');
@@ -63,7 +78,10 @@ class Suppliers extends Persons
return $this->response->setJSON($suggestions);
}
public function suggestSearch(): ResponseInterface
/**
* @return ResponseInterface
*/
public function suggest_search(): ResponseInterface
{
$search = $this->request->getPost('term');
$suggestions = $this->supplier->get_search_suggestions($search, false);
@@ -71,9 +89,15 @@ class Suppliers extends Persons
return $this->response->setJSON($suggestions);
}
public function getView(int $supplierId = NEW_ENTRY): string
/**
* Loads the supplier edit form
*
* @param int $supplier_id
* @return string
*/
public function getView(int $supplier_id = NEW_ENTRY): string
{
$info = $this->supplier->get_info($supplierId);
$info = $this->supplier->get_info($supplier_id);
foreach (get_object_vars($info) as $property => $value) {
$info->$property = $value;
}
@@ -83,23 +107,25 @@ class Suppliers extends Persons
return view("suppliers/form", $data);
}
public function getAttributes(int $supplierId = NEW_ENTRY): string
/**
* Inserts/updates a supplier
*
* @param int $supplier_id
* @return ResponseInterface
*/
public function postSave(int $supplier_id = NEW_ENTRY): ResponseInterface
{
return $this->getPersonAttributes($supplierId, Attribute::SHOW_IN_SUPPLIERS);
}
public function postSave(int $supplierId = NEW_ENTRY): ResponseInterface
{
$firstName = $this->request->getPost('first_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$lastName = $this->request->getPost('last_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$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);
$email = strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL));
$firstName = $this->nameize($firstName);
$lastName = $this->nameize($lastName);
// Format first and last name properly
$first_name = $this->nameize($first_name);
$last_name = $this->nameize($last_name);
$personData = [
'first_name' => $firstName,
'last_name' => $lastName,
$person_data = [
'first_name' => $first_name,
'last_name' => $last_name,
'gender' => $this->request->getPost('gender'),
'email' => $email,
'phone_number' => $this->request->getPost('phone_number', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
@@ -112,7 +138,7 @@ class Suppliers extends Persons
'comments' => $this->request->getPost('comments', FILTER_SANITIZE_FULL_SPECIAL_CHARS)
];
$supplierData = [
$supplier_data = [
'company_name' => $this->request->getPost('company_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
'agency_name' => $this->request->getPost('agency_name', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
'category' => $this->request->getPost('category', FILTER_SANITIZE_FULL_SPECIAL_CHARS),
@@ -120,43 +146,47 @@ class Suppliers extends Persons
'tax_id' => $this->request->getPost('tax_id', FILTER_SANITIZE_NUMBER_INT)
];
if ($this->supplier->save_supplier($personData, $supplierData, $supplierId)) {
$personId = $supplierId == NEW_ENTRY ? $supplierData['person_id'] : $supplierId;
$this->savePersonAttributes($personId, Attribute::SHOW_IN_SUPPLIERS);
if ($supplierId == NEW_ENTRY) {
if ($this->supplier->save_supplier($person_data, $supplier_data, $supplier_id)) {
// New supplier
if ($supplier_id == NEW_ENTRY) {
return $this->response->setJSON([
'success' => true,
'message' => lang('Suppliers.successful_adding') . ' ' . $supplierData['company_name'],
'id' => $supplierData['person_id']
'message' => lang('Suppliers.successful_adding') . ' ' . $supplier_data['company_name'],
'id' => $supplier_data['person_id']
]);
} else {
} else { // Existing supplier
return $this->response->setJSON([
'success' => true,
'message' => lang('Suppliers.successful_updating') . ' ' . $supplierData['company_name'],
'id' => $supplierId
'message' => lang('Suppliers.successful_updating') . ' ' . $supplier_data['company_name'],
'id' => $supplier_id
]);
}
} else {
} else { // Failure
return $this->response->setJSON([
'success' => false,
'message' => lang('Suppliers.error_adding_updating') . ' ' . $supplierData['company_name'],
'message' => lang('Suppliers.error_adding_updating') . ' ' . $supplier_data['company_name'],
'id' => NEW_ENTRY
]);
}
}
/**
* This deletes suppliers from the suppliers table
*
* @return ResponseInterface
*/
public function postDelete(): ResponseInterface
{
$suppliersToDelete = $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT);
$suppliers_to_delete = $this->request->getPost('ids', FILTER_SANITIZE_NUMBER_INT);
if ($this->supplier->delete_list($suppliersToDelete)) {
if ($this->supplier->delete_list($suppliers_to_delete)) {
return $this->response->setJSON([
'success' => true,
'message' => lang('Suppliers.successful_deleted') . ' ' . count($suppliersToDelete) . ' ' . lang('Suppliers.one_or_multiple')
'message' => lang('Suppliers.successful_deleted') . ' ' . count($suppliers_to_delete) . ' ' . lang('Suppliers.one_or_multiple')
]);
} else {
return $this->response->setJSON(['success' => false, 'message' => lang('Suppliers.cannot_be_deleted')]);
}
}
}
}

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

@@ -1,61 +0,0 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
use Config\Database;
class AddPersonToAttributeLinks extends Migration
{
public function up(): void
{
helper('migration');
// First, modify the generated unique column to include person_id
// Drop the existing unique constraint
$this->db->query('ALTER TABLE `ospos_attribute_links` DROP INDEX `attribute_links_uq3`');
$this->db->query('ALTER TABLE `ospos_attribute_links` DROP COLUMN `generated_unique_column`');
// Add person_id column
$this->db->query('ALTER TABLE `ospos_attribute_links` ADD COLUMN `person_id` INT(10) NULL AFTER `receiving_id`');
// Add index for person_id
$this->db->query('ALTER TABLE `ospos_attribute_links` ADD KEY `person_id` (`person_id`)');
// Add foreign key constraint for person_id
$this->db->query('ALTER TABLE `ospos_attribute_links` ADD CONSTRAINT `ospos_attribute_links_ibfk_6` FOREIGN KEY (`person_id`) REFERENCES `ospos_people` (`person_id`) ON DELETE CASCADE');
// Recreate the generated unique column with person_id support
// This ensures uniqueness for both item attributes and person attributes
$this->db->query("ALTER TABLE `ospos_attribute_links`
ADD COLUMN `generated_unique_column` VARCHAR(255) GENERATED ALWAYS AS (
CASE
WHEN `sale_id` IS NULL AND `receiving_id` IS NULL AND `item_id` IS NOT NULL THEN CONCAT('item-', `definition_id`, '-', `item_id`)
WHEN `sale_id` IS NULL AND `receiving_id` IS NULL AND `item_id` IS NULL AND `person_id` IS NOT NULL THEN CONCAT('person-', `definition_id`, '-', `person_id`)
ELSE NULL
END
) STORED");
// Re-add unique constraint
$this->db->query('ALTER TABLE `ospos_attribute_links` ADD UNIQUE INDEX `attribute_links_uq3` (`generated_unique_column`)');
}
public function down(): void
{
// Drop person_id related constraints and column
$this->db->query('ALTER TABLE `ospos_attribute_links` DROP INDEX `attribute_links_uq3`');
$this->db->query('ALTER TABLE `ospos_attribute_links` DROP COLUMN `generated_unique_column`');
$this->db->query('ALTER TABLE `ospos_attribute_links` DROP FOREIGN KEY `ospos_attribute_links_ibfk_6`');
$this->db->query('ALTER TABLE `ospos_attribute_links` DROP COLUMN `person_id`');
// Restore original generated column
$this->db->query("ALTER TABLE `ospos_attribute_links`
ADD COLUMN `generated_unique_column` VARCHAR(255) GENERATED ALWAYS AS (
CASE
WHEN `sale_id` IS NULL AND `receiving_id` IS NULL AND `item_id` IS NOT NULL THEN CONCAT(`definition_id`, '-', `item_id`)
ELSE NULL
END
) STORED");
$this->db->query('ALTER TABLE `ospos_attribute_links` ADD UNIQUE INDEX `attribute_links_uq3` (`generated_unique_column`)');
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddPersonAttributeFlag extends Migration
{
public function up(): void
{
$this->db->query('ALTER TABLE `ospos_attribute_definitions` ADD COLUMN `person_attribute` TINYINT(1) DEFAULT 0 AFTER `definition_flags`');
}
public function down(): void
{
$this->db->query('ALTER TABLE `ospos_attribute_definitions` DROP COLUMN `person_attribute`');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddShortcutKeys extends Migration
{
public function up(): void
{
$shortcutValues = [
['key' => 'key_cancel', 'value' => '27 | ESC'],
['key' => 'key_items', 'value' => '49 | ALT + 1'],
['key' => 'key_customers', 'value' => '50 | ALT + 2'],
['key' => 'key_suspend', 'value' => '51 | ALT + 3'],
['key' => 'key_suspended', 'value' => '52 | ALT + 4'],
['key' => 'key_amount', 'value' => '53 | ALT + 5'],
['key' => 'key_payment', 'value' => '54 | ALT + 6'],
['key' => 'key_complete', 'value' => '55 | ALT + 7'],
['key' => 'key_finish', 'value' => '56 | ALT + 8'],
['key' => 'key_help', 'value' => '57 | ALT + 9'],
];
$this->db->table('app_config')->ignore(true)->insertBatch($shortcutValues);
}
public function down(): void
{
$shortcutKeys = [
'key_cancel',
'key_items',
'key_customers',
'key_suspend',
'key_suspended',
'key_amount',
'key_payment',
'key_complete',
'key_finish',
'key_help',
];
$this->db->table('app_config')
->whereIn('key', $shortcutKeys)
->delete();
}
}

View File

@@ -272,6 +272,9 @@ function get_payment_options(): array
$payments[lang('Sales.upi')] = lang('Sales.upi');
}
$payments[lang('Sales.bank_transfer')] = lang('Sales.bank_transfer');
$payments[lang('Sales.wallet')] = lang('Sales.wallet');
return $payments;
}

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Amount Due",
"amount_tendered" => "Amount Tendered",
"authorized_signature" => "Authorised Signature",
"bank_transfer" => "Bank Transfer",
"cancel_sale" => "Cancel",
"cash" => "Cash",
"cash_1" => "",
@@ -223,6 +224,7 @@ return [
"update" => "Update",
"upi" => "UPI",
"visa" => "",
"wallet" => "Wallet",
"wholesale" => "",
"work_order" => "Work Order",
"work_order_number" => "Work Order Number",

View File

@@ -23,10 +23,6 @@ return [
"new" => "New Attribute",
"no_attributes_to_display" => "No Attributes to display",
"receipt_visibility" => "Receipt",
"show_in_customers" => "Show in customers",
"show_in_customers_visibility" => "Customers",
"show_in_employees" => "Show in employees",
"show_in_employees_visibility" => "Employees",
"show_in_items" => "Show in items",
"show_in_items_visibility" => "Items",
"show_in_receipt" => "Show in receipt",
@@ -34,7 +30,5 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_suppliers" => "Show in suppliers",
"show_in_suppliers_visibility" => "Suppliers",
"update" => "Update Attribute",
];

View File

@@ -302,6 +302,10 @@ return [
"suggestions_layout" => "Search Suggestions Layout",
"suggestions_second_column" => "Column 2",
"suggestions_third_column" => "Column 3",
"shortcuts" => "Shortcuts",
"shortcuts_configuration" => "Sales Keyboard Shortcut Configuration",
"shortcuts_duplicate_bindings" => "Shortcut bindings must be unique.",
"shortcuts_save_error" => "Unable to save shortcut settings.",
"system_conf" => "Setup & Conf",
"system_info" => "System Info",
"table" => "Table",

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Amount Due",
"amount_tendered" => "Amount Tendered",
"authorized_signature" => "Authorized Signature",
"bank_transfer" => "Bank Transfer",
"cancel_sale" => "Cancel",
"cash" => "Cash",
"cash_1" => "",
@@ -223,6 +224,7 @@ return [
"update" => "Update",
"upi" => "UPI",
"visa" => "",
"wallet" => "Wallet",
"wholesale" => "",
"work_order" => "Work Order",
"work_order_number" => "Work Order Number",

View File

@@ -26,7 +26,7 @@ return [
"cost_price_required" => "Precio al Por Mayor es un campo requerido.",
"count" => "Actualizar Inventario",
"csv_import_failed" => "Falló la importación de Hoja de Cálculo",
"csv_import_invalid_location" => "Ubicación(es) de stock inválida(s) encontrada(s): {0}. Solo ubicaciones de stock válidas son permitidas.",
"csv_import_invalid_location" => "Se encontraron ubicaciones de stock no válidas: {0}. Solo se permiten ubicaciones de stock válidas.",
"csv_import_nodata_wrongformat" => "El archivo subido no tiene datos o el formato es incorrecto.",
"csv_import_partially_failed" => "Hubo {0} falla(s) en la importación de producto(s) en la(s) línea(s): {1}. Ninguna fila ha sido importada.",
"csv_import_success" => "Se importaron los articulos exitosamente.",

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Monto Adeudado",
"amount_tendered" => "Cantidad Recibida",
"authorized_signature" => "Firma Autorizada",
"bank_transfer" => "Transferencia Bancaria",
"cancel_sale" => "Cancelar Venta",
"cash" => "Efectivo",
"cash_1" => "1",
@@ -222,6 +223,7 @@ return [
"update" => "Editar",
"upi" => "PIN UPI",
"visa" => "Tarjeta Visa",
"wallet" => "Monedero",
"wholesale" => "Precio al por mayor",
"work_order" => "Orden trabajo",
"work_order_number" => "Numero Orden Trabajo",

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Monto de adeudo",
"amount_tendered" => "Cantidad Recibida",
"authorized_signature" => "Firma Autorizada",
"bank_transfer" => "Transferencia Bancaria",
"cancel_sale" => "Cancelar",
"cash" => "Efectivo",
"cash_1" => "",
@@ -222,6 +223,7 @@ return [
"update" => "Actualizar",
"upi" => "UPI",
"visa" => "",
"wallet" => "Monedero",
"wholesale" => "",
"work_order" => "Orden de trabajo",
"work_order_number" => "Número de orden de trabajo",

View File

@@ -9,6 +9,7 @@ return [
"amount_due" => "Montant à Payer",
"amount_tendered" => "Montant Présenté",
"authorized_signature" => "Signature autorisée",
"bank_transfer" => "Virement Bancaire",
"cancel_sale" => "Annuler la Vente",
"cash" => "Espèce",
"cash_1" => "",
@@ -222,6 +223,7 @@ return [
"update" => "Éditer",
"upi" => "UPI",
"visa" => "",
"wallet" => "Portefeuille",
"wholesale" => "",
"work_order" => "Commande de travail",
"work_order_number" => "Numéro de commande",

View File

@@ -38,7 +38,7 @@ return [
"february" => "",
"march" => "",
"april" => "",
"mayl" => "",
"may" => "",
"june" => "",
"july" => "",
"august" => "",
@@ -46,4 +46,4 @@ return [
"october" => "",
"november" => "",
"december" => "",
];
];

View File

@@ -38,7 +38,7 @@ return [
"february" => "",
"march" => "",
"april" => "",
"mayl" => "",
"may" => "",
"june" => "",
"july" => "",
"august" => "",
@@ -46,4 +46,4 @@ return [
"october" => "",
"november" => "",
"december" => "",
];
];

View File

@@ -38,7 +38,7 @@ return [
"february" => "ഫെബ്രുവരി",
"march" => "മാർച്ച്",
"april" => "ഏപ്രിൽ",
"mayl" => "മേയ്",
"may" => "മേയ്",
"june" => "ജൂൺ",
"july" => "ജൂലൈ",
"august" => "ആഗസ്റ്റ്",
@@ -46,4 +46,4 @@ return [
"october" => "ഒക്ടോബർ",
"november" => "നവംബർ",
"december" => "ഡിസംബർ",
];
];

View File

@@ -38,7 +38,7 @@ return [
"february" => "Februar",
"march" => "Mars",
"april" => "April",
"mayl" => "Mai",
"may" => "Mai",
"june" => "Juni",
"july" => "Juli",
"august" => "August",
@@ -46,4 +46,4 @@ return [
"october" => "Oktober",
"november" => "November",
"december" => "Desember",
];
];

View File

@@ -1,12 +1,12 @@
<?php
return [
"all" => "ทั้งหมด",
"columns" => "คอลัมน์",
"hide_show_pagination" => "ซ่อน/แสดง รายการหน้า",
"loading" => "กำลังดำเนินการ รอสักครู่",
"page_from_to" => "แสดง {0} ถึง {1} จาก {2} รายการ",
"refresh" => "Refresh ข้อมูล",
"rows_per_page" => "{0} รายการ/หน้า",
"toggle" => "ซ่อน/แสดง",
'all' => "ทั้งหมด",
'columns' => "คอลัมน์",
'hide_show_pagination' => "ซ่อน/แสดง รายการหน้า",
'loading' => "กำลังดำเนินการ รอสักครู่ ...",
'page_from_to' => "แสดง {0} ถึง {1} จาก {2} รายการ",
'refresh' => "Refresh ข้อมูล",
'rows_per_page' => "{0} รายการ/หน้า",
'toggle' => "ซ่อน/แสดง",
];

View File

@@ -9,7 +9,9 @@ return [
"login" => "ลงชื่อเข้าใช้",
"logout" => "ออกจากระบบ",
"migration_needed" => "การย้ายฐานข้อมูลไปยัง {0} จะเริ่มต้นหลังจากเข้าสู่ระบบ",
"migration_required" => "",
"migration_required" => "จําเป็นต้องมีการปรับปรุงฐานข้อมูล",
"migration_auth_message" => "ผู้ดูแลระบบจำเป็นต้องมีสิทธิ์ในการปรับปรุงฐานข้อมูลเวอร์ชั่น {0} กรุณาเข้าระบบเพื่อดำเนินการต่อ",
"migration_complete_redirect" => "ทำการปรับปรุงฐานข้อมูลเรียบร้อย กำลังดำเนินการไปหน้าเข้าสู่ระบบ ...",
"migration_auth_message" => "",
"migration_initializing" => "",
"migration_running" => "",
@@ -17,7 +19,6 @@ return [
"migration_complete_login" => "",
"migration_failed" => "",
"migration_error_connection" => "",
"migration_complete_redirect" => "",
"password" => "รหัสผ่าน",
"required_username" => "จำเป็นต้องระบุชื่อผู้ใช้งาน",
"username" => "ชื่อผู้ใช้",

View File

@@ -1,232 +1,232 @@
<?php
return [
"customers_available_points" => "คะแนนที่มี",
"rewards_package" => "คะแนนสะสม",
"rewards_remaining_balance" => "คะแนนสะสมคงเหลือ ",
"account_number" => "บัญชี #",
"add_payment" => "เพิ่มบิล",
"amount_due" => "ยอดค้างชำระ",
"amount_tendered" => "ชำระเข้ามา",
"authorized_signature" => "ลายเซ็นผู้มีอำนาจ",
"cancel_sale" => "ยกเลิกการขาย",
"cash" => "เงินสด",
"cash_1" => "",
"cash_2" => "",
"cash_3" => "",
"cash_4" => "",
"cash_adjustment" => "การปรับเงินสดขาย",
"cash_deposit" => "ฝากเงินสด",
"cash_filter" => "เงินสด",
"change_due" => "เงินทอน",
"change_price" => "เปลี่ยนราคาขาย",
"check" => "โอนเงิน/พร้อมเพย์/เช็ค",
"check_balance" => "เช็คยอดคงเหลือ",
"check_filter" => "ตรวจสอบ",
"close" => "",
"comment" => "หมายเหตุ",
"comments" => "หมายเหตุ",
"company_name" => "",
"complete" => "",
"complete_sale" => "จบการขาย",
"confirm_cancel_sale" => "แน่ใจหรือไม่ที่จะล้างการขายนี้? ทุกรายการจะถูกลบทั้งหมด",
"confirm_delete" => "โปรดยืนยันการลบรายการขายที่เลือกไว้ ?",
"confirm_restore" => "คุณแน่ใจหรือไม่ว่าต้องการยกเลิกการขายที่เลือกไว้?",
"credit" => "เครดิตการ์ด",
"credit_deposit" => "เงินฝากเครดิต",
"credit_filter" => "บัตรเครติด",
"current_table" => "",
"customer" => "ลูกค้า",
"customer_address" => "Customer Address",
"customer_discount" => "ส่วนลด",
"customer_email" => "Customer Email",
"customer_location" => "Customer Location",
"customer_mailchimp_status" => "สถานะของระบบส่งเมล์เมล์ชิม",
"customer_optional" => "(ต้องระบุวันที่ชำระเงิน)",
"customer_required" => "(ต้องระบุ)",
"customer_total" => "Total",
"customer_total_spent" => "",
"daily_sales" => "",
"date" => "วันที่ขาย",
"date_range" => "ระหว่างวันที่",
"date_required" => "กรุณากรอกวันที่ให้ถูกต้อง",
"date_type" => "กรุณากรอกข้อมูลในช่องวันที่",
"debit" => "บัตรประชารัฐ/เดบิตการ์ด",
"debit_filter" => "",
"delete" => "อนุญาตให้ลบ",
"delete_confirmation" => "แน่ใจหรือไม่ที่จะลบรายการขายนี้, ลบแล้วไม่สามารถเรียกกลับคืนใด้",
"delete_entire_sale" => "ลบการขายทั้งหมด",
"delete_successful" => "คุณลบการขายสำเร็จ",
"delete_unsuccessful" => "คุณลบการขายไม่สำเร็จ",
"description_abbrv" => "รายละเอียด",
"discard" => "ยกเลิก",
"discard_quote" => "",
"discount" => "ส่วนลด %",
"discount_included" => "% ส่วนลด",
"discount_short" => "%",
"due" => "วันครบกำหนด",
"due_filter" => "วันที่ครบกำหนด",
"edit" => "แก้ไข",
"edit_item" => "แก้ไขสินค้า",
"edit_sale" => "แก้ไขการขาย",
"email_receipt" => "อีเมลบิล",
"employee" => "พนักงาน",
"entry" => "การนำเข้า",
"error_editing_item" => "แก้ไขสินค้าล้มเหลว",
"negative_price_invalid" => "",
"negative_quantity_invalid" => "",
"negative_discount_invalid" => "",
"discount_percent_exceeds_100" => "",
"discount_exceeds_item_total" => "",
"negative_total_invalid" => "",
"find_or_scan_item" => "ค้นหาสินค้า",
"find_or_scan_item_or_receipt" => "ค้นหา หรือ แสกนรายการ หรือ ใบเสร็จ",
"giftcard" => "บัตรของขวัญ",
"giftcard_balance" => "ยอดคงเหลือบัตรของขวัญ",
"giftcard_filter" => "",
"giftcard_number" => "เลขที่บัตรของขวัญ",
"group_by_category" => "กลุ่มตามหมวดหมู่",
"group_by_type" => "กลุ่มตามประเภท",
"hsn" => "HSN",
"id" => "เลขที่ขาย",
"include_prices" => "รวมในราคา?",
"invoice" => "ใบแจ้งหนี้",
"invoice_confirm" => "ใบแจ้งหนี้นี้จะถูกส่งไปที่",
"invoice_enable" => "เลขที่ใบแจ้งหนี้",
"invoice_filter" => "ใบแจ้งหนี้",
"invoice_no_email" => "ลูกค้ารายนี้ไม่มีที่อยู่อีเมล",
"invoice_number" => "เลขใบแจ้งหนี้ #",
"invoice_number_duplicate" => "ใบแจ้งหนี้หมายเลข {0} จะต้องไม่ซ้ำกัน",
"invoice_sent" => "ส่งใบแจ้งหนี้ไปที่",
"invoice_total" => "ยอดรวมในใบแจ้งหนี้",
"invoice_type_custom_invoice" => "ใบแจ้งหนี้ที่กำหนดเอง (custom_invoice.php)",
"invoice_type_custom_tax_invoice" => "ใบกำกับภาษีที่กำหนดเอง (custom_tax_invoice.php)",
"invoice_type_invoice" => "ใบแจ้งหนี้ (invoice.php)",
"invoice_type_tax_invoice" => "ใบกำกับภาษี (tax_invoice.php)",
"invoice_unsent" => "ไม่สามารถส่งใบแจ้งหนี้ถึง",
"invoice_update" => "คำนวณใหม่",
"item_insufficient_of_stock" => "จำนวนสินค้าไม่เพียงพอ",
"item_name" => "ชื่อสินค้า",
"item_number" => "สินค้า #",
"item_out_of_stock" => "สินค้าจำหน่ายหมด",
"key_browser" => "ความช่วยเหลือ",
"key_cancel" => "ยกเลิกใบเสนอราคา/ใบแจ้งหนี้ /ใบการขาย นี้",
"key_customer_search" => "ค้นหาลูกค้า",
"key_finish_quote" => "จบใบเสนอราคา/ใบแจ้งหนี้โดยไม่ต้องชำระเงิน",
"key_finish_sale" => "เพิ่มการชำระเงินและใบแจ้งหนี้ /ใบรายการขาย",
"key_full" => "เปิดแบบเต็มหน้าจอ",
"key_function" => "ฟังก์ชั่น",
"key_help" => "คำสั่งลัดงานขาย",
"key_help_modal" => "เปิดหน้าต่างคำสั่งลัดงานขาย",
"key_in" => "ขยายเข้า",
"key_item_search" => "ค้นหารายการขาย",
"key_out" => "ขยายออก",
"key_payment" => "เพิ่มการชำระเงิน",
"key_print" => "พิมพ์หน้านี้",
"key_restore" => "คืนการแสดงผลแบบดั้งเดิม/ขยาย",
"key_search" => "ค้นหาตารางรายงาน",
"key_suspend" => "พักรายการขายปัจจุบัน",
"key_suspended" => "แสดงรายการขายที่พักไว้",
"key_system" => "ทางลัดระบบ",
"key_tendered" => "แก้ไขจำนวนเงินรับมา",
"key_title" => "ทางลัดคียบอร์ดงานขาย",
"mc" => "",
"mode" => "รูปแบบการลงทะเบียน",
"must_enter_numeric" => "จำนวนที่ถุกประมูลต้องใส่ข้อมุลที่เปนตัวเลข",
"must_enter_numeric_giftcard" => "เลขที่บัตรของขวัญ ต้องใส่ตัวเลขเท่านั้น",
"new_customer" => "ลูกค้าใหม่",
"new_item" => "สินค้าใหม่",
"no_description" => "ไม่ระบุรายละเอียด",
"no_filter" => "ทั้งหมด",
"no_items_in_cart" => "ไม่พบสินค้าในตระกร้า",
"no_sales_to_display" => "ไม่มีการขายที่จะแสดง",
"none_selected" => "คุณยังไม่ได้เลือกการขายที่จะลบ",
"nontaxed_ind" => " . ",
"not_authorized" => "การกระทำนี้ไม่ได้รับอนุญาต",
"one_or_multiple" => "การขาย",
"payment" => "รูปแบบชำระเงิน",
"payment_amount" => "จำนวน",
"payment_not_cover_total" => "จำนวนเงินที่ชำระต้องมากกว่าหรือเท่ากับยอดรวม",
"payment_type" => "ชำระโดย",
"payments" => "",
"payments_total" => "ยอดชำระแล้ว",
"price" => "ราคา",
"print_after_sale" => "พิมพ์บิลหลังการขาย",
"quantity" => "จำนวน",
"quantity_less_than_reorder_level" => "คำเตือน ถ้าจำนวนของไม่เพียงพอกับความต้องการหรือไม่ตรงกับยอดในบันชี ก็สามารถทำการขายได้ แต่ต้องเชคปริมานสินค้าคงคลัง",
"quantity_less_than_zero" => "คำเตือน: ถ้าจำนวนของไม่เพียงพอกับความต้องการหรือไม่ตรงกับยอดในบัญชี ก็สามารถทำการขายได้ แต่ต้องตรวจสอบปริมาญสินค้าคงคลังก่อน",
"quantity_of_items" => "ปริมาณของ {0} รายการ",
"quote" => "ใบเสนอราคา",
"quote_number" => "หมายเลขอ้างอิง",
"quote_number_duplicate" => "หมายเลขอ้างอิงต้องไม่ซ้ำกัน",
"quote_sent" => "ส่งการอ้างอิงถึง",
"quote_unsent" => "ส่งการอ้างอิงถึงผิดพลาด",
"receipt" => "บิลขาย",
"receipt_no_email" => "ลูกค้านี้ไม่มีที่อยู่อีเมล์",
"receipt_number" => "จุดขาย#",
"receipt_sent" => "ส่งใบเสร็จไปที่",
"receipt_unsent" => "ไม่สามารถส่งใบเสร็จไปที่",
"refund" => "ประเภทการยกเลิกการขาย",
"register" => "ลงทะเบียนขาย",
"remove_customer" => "ลบลูกค้า",
"remove_discount" => "",
"return" => "คืน",
"rewards" => "คะแนนสะสม",
"rewards_balance" => "คะแนนสะสมคงเหลือ",
"sale" => "ขาย",
"sale_by_invoice" => "การขายโดยใบแจ้งหนี้",
"sale_for_customer" => "ลูกค้า:",
"sale_time" => "เวลา",
"sales_tax" => "ภาษีการขาย",
"sales_total" => "",
"select_customer" => "เลือกลูกค้า (Optional)",
"send_invoice" => "ส่งใบแจ้งหนี้",
"send_quote" => "ส่งใบเสนอราคา",
"send_receipt" => "ส่งใบเสร็จ",
"send_work_order" => "ส่งคำสั่งงาน",
"serial" => "หมายเลขซีเรียล",
"service_charge" => "",
"show_due" => "",
"show_invoice" => "ใบแจ้งหนี้",
"show_receipt" => "ใบเสร็จ",
"start_typing_customer_name" => "เริ่มต้นพิมพ์ชื่อลูกค้า...",
"start_typing_item_name" => "เริ่มต้นพิมพ์ชื่อสินค้า หรือ สแกนบาร์โค๊ด...",
"stock" => "คลังสินค้า",
"stock_location" => "ที่เก็บ",
"sub_total" => "ยอดรวมย่อย",
"successfully_deleted" => "ลบการขายสมยูรณ์",
"successfully_restored" => "คุณกู้คืนสำเร็จแล้ว",
"successfully_suspended_sale" => "การขายของคุณถูกระงับเรียบร้อย",
"successfully_updated" => "อัพเดทการขายสมบูรณ์",
"suspend_sale" => "พักรายการ",
"suspended_doc_id" => "รหัสเอกสาร",
"suspended_sale_id" => "รหัสการขายที่ถูกพัก",
"suspended_sales" => "การขายที่พักไว้",
"table" => "โต๊ะ",
"takings" => "การขายประจำวัน",
"tax" => "ภาษี",
"tax_id" => "รหัสภาษี",
"tax_invoice" => "ใบกำกับภาษี",
"tax_percent" => "ภาษี %",
"taxed_ind" => "",
"total" => "ยอดรวม",
"total_tax_exclusive" => "ยอดไม่รวมภาษี",
"transaction_failed" => "การดำเนินการขายล้มเหลว",
"unable_to_add_item" => "เพิ่มรายการไปยังการขายล้มเหลว",
"unsuccessfully_deleted" => "ลบการขายไม่สำเร็จ",
"unsuccessfully_restored" => "การคืนค่ารายการขายล้มเหลว",
"unsuccessfully_suspended_sale" => "การขายของคุณถูกระงับเรียบร้อย",
"unsuccessfully_updated" => "อัพเดทการขายไม่สมบูรณ์",
"unsuspend" => "ยกเลิกการระงับ",
"unsuspend_and_delete" => "ยกเลิกการระงับ และ ลบ",
"update" => "แก้ไข",
"upi" => "ยูพีไอ",
"visa" => "",
"wholesale" => "",
"work_order" => "คำสั่งงาน",
"work_order_number" => "หมายเลขคำสั่งงาน",
"work_order_number_duplicate" => "หมายเลขคำสั่งงานต้องไม่ซ้ำกัน",
"work_order_sent" => "คำสั่งงานส่งถึง",
"work_order_unsent" => "ส่งคำสั่งงานล้มเหลว",
"selected_customer" => "ลูกค้าที่เลือก",
'customers_available_points' => "คะแนนที่มี",
'rewards_package' => "คะแนนสะสม",
'rewards_remaining_balance' => "คะแนนสะสมคงเหลือ ",
'account_number' => "บัญชี #",
'add_payment' => "เพิ่มบิล",
'amount_due' => "ยอดค้างชำระ",
'amount_tendered' => "ชำระเข้ามา",
'authorized_signature' => "ลายเซ็นผู้มีอำนาจ",
'cancel_sale' => "ยกเลิกการขาย",
'cash' => "เงินสด",
'cash_1' => "",
'cash_2' => "",
'cash_3' => "",
'cash_4' => "",
'cash_adjustment' => "การปรับเงินสดขาย",
'cash_deposit' => "ฝากเงินสด",
'cash_filter' => "เงินสด",
'change_due' => "เงินทอน",
'change_price' => "เปลี่ยนราคาขาย",
'check' => "โอนเงิน/พร้อมเพย์/เช็ค",
'check_balance' => "เช็คยอดคงเหลือ",
'check_filter' => "ตรวจสอบ",
'close' => "",
'comment' => "หมายเหตุ",
'comments' => "หมายเหตุ",
'company_name' => "",
'complete' => "",
'complete_sale' => "จบการขาย",
'confirm_cancel_sale' => "แน่ใจหรือไม่ที่จะล้างการขายนี้? ทุกรายการจะถูกลบทั้งหมด",
'confirm_delete' => "โปรดยืนยันการลบรายการขายที่เลือกไว้ ?",
'confirm_restore' => "คุณแน่ใจหรือไม่ว่าต้องการยกเลิกการขายที่เลือกไว้?",
'credit' => "เครดิตการ์ด",
'credit_deposit' => "เงินฝากเครดิต",
'credit_filter' => "บัตรเครติด",
'current_table' => "",
'customer' => "ลูกค้า",
'customer_address' => "Customer Address",
'customer_discount' => "ส่วนลด",
'customer_email' => "Customer Email",
'customer_location' => "Customer Location",
'customer_mailchimp_status' => "สถานะของระบบส่งเมล์เมล์ชิม",
'customer_optional' => "(ต้องระบุวันที่ชำระเงิน)",
'customer_required' => "(ต้องระบุ)",
'customer_total' => "Total",
'customer_total_spent' => "",
'daily_sales' => "",
'date' => "วันที่ขาย",
'date_range' => "ระหว่างวันที่",
'date_required' => "กรุณากรอกวันที่ให้ถูกต้อง",
'date_type' => "กรุณากรอกข้อมูลในช่องวันที่",
'debit' => "บัตรประชารัฐ/เดบิตการ์ด",
'debit_filter' => "",
'delete' => "อนุญาตให้ลบ",
'delete_confirmation' => "แน่ใจหรือไม่ที่จะลบรายการขายนี้, ลบแล้วไม่สามารถเรียกกลับคืนใด้",
'delete_entire_sale' => "ลบการขายทั้งหมด",
'delete_successful' => "คุณลบการขายสำเร็จ",
'delete_unsuccessful' => "คุณลบการขายไม่สำเร็จ",
'description_abbrv' => "รายละเอียด",
'discard' => "ยกเลิก",
'discard_quote' => "",
'discount' => "ส่วนลด %",
'discount_included' => "% ส่วนลด",
'discount_short' => "%",
'due' => "วันครบกำหนด",
'due_filter' => "วันที่ครบกำหนด",
'edit' => "แก้ไข",
'edit_item' => "แก้ไขสินค้า",
'edit_sale' => "แก้ไขการขาย",
'email_receipt' => "อีเมลบิล",
'employee' => "พนักงาน",
'entry' => "การนำเข้า",
'error_editing_item' => "แก้ไขสินค้าล้มเหลว",
'negative_price_invalid' => "ราคาไม่สามารถเป็นค่าติดลบได้",
'negative_quantity_invalid' => "จำนวนไม่สามารถเป็นค่าติดลบได้",
'negative_discount_invalid' => "ส่วนลดไม่สามารถเป็นค่าติดลบได้",
'discount_percent_exceeds_100' => "ส่วนลดเปอร์เซ็นต์มีค่าได้ไม่เกิน 100%",
'discount_exceeds_item_total' => "ส่วนลดต้องไม่เกินจำนวนรายการขายทั้งหมด",
'negative_total_invalid' => "",
'find_or_scan_item' => "ค้นหาสินค้า",
'find_or_scan_item_or_receipt' => "ค้นหา หรือ แสกนรายการ หรือ ใบเสร็จ",
'giftcard' => "บัตรของขวัญ",
'giftcard_balance' => "ยอดคงเหลือบัตรของขวัญ",
'giftcard_filter' => "",
'giftcard_number' => "เลขที่บัตรของขวัญ",
'group_by_category' => "กลุ่มตามหมวดหมู่",
'group_by_type' => "กลุ่มตามประเภท",
'hsn' => "HSN",
'id' => "เลขที่ขาย",
'include_prices' => "รวมในราคา?",
'invoice' => "ใบแจ้งหนี้",
'invoice_confirm' => "ใบแจ้งหนี้นี้จะถูกส่งไปที่",
'invoice_enable' => "เลขที่ใบแจ้งหนี้",
'invoice_filter' => "ใบแจ้งหนี้",
'invoice_no_email' => "ลูกค้ารายนี้ไม่มีที่อยู่อีเมล",
'invoice_number' => "เลขใบแจ้งหนี้ #",
'invoice_number_duplicate' => "ใบแจ้งหนี้หมายเลข {0} จะต้องไม่ซ้ำกัน",
'invoice_sent' => "ส่งใบแจ้งหนี้ไปที่",
'invoice_total' => "ยอดรวมในใบแจ้งหนี้",
'invoice_type_custom_invoice' => "ใบแจ้งหนี้ที่กำหนดเอง (custom_invoice.php)",
'invoice_type_custom_tax_invoice' => "ใบกำกับภาษีที่กำหนดเอง (custom_tax_invoice.php)",
'invoice_type_invoice' => "ใบแจ้งหนี้ (invoice.php)",
'invoice_type_tax_invoice' => "ใบกำกับภาษี (tax_invoice.php)",
'invoice_unsent' => "ไม่สามารถส่งใบแจ้งหนี้ถึง",
'invoice_update' => "คำนวณใหม่",
'item_insufficient_of_stock' => "จำนวนสินค้าไม่เพียงพอ",
'item_name' => "ชื่อสินค้า",
'item_number' => "สินค้า #",
'item_out_of_stock' => "สินค้าจำหน่ายหมด",
'key_browser' => "ความช่วยเหลือ",
'key_cancel' => "ยกเลิกใบเสนอราคา/ใบแจ้งหนี้ /ใบการขาย นี้",
'key_customer_search' => "ค้นหาลูกค้า",
'key_finish_quote' => "จบใบเสนอราคา/ใบแจ้งหนี้โดยไม่ต้องชำระเงิน",
'key_finish_sale' => "เพิ่มการชำระเงินและใบแจ้งหนี้ /ใบรายการขาย",
'key_full' => "เปิดแบบเต็มหน้าจอ",
'key_function' => "ฟังก์ชั่น",
'key_help' => "คำสั่งลัดงานขาย",
'key_help_modal' => "เปิดหน้าต่างคำสั่งลัดงานขาย",
'key_in' => "ขยายเข้า",
'key_item_search' => "ค้นหารายการขาย",
'key_out' => "ขยายออก",
'key_payment' => "เพิ่มการชำระเงิน",
'key_print' => "พิมพ์หน้านี้",
'key_restore' => "คืนการแสดงผลแบบดั้งเดิม/ขยาย",
'key_search' => "ค้นหาตารางรายงาน",
'key_suspend' => "พักรายการขายปัจจุบัน",
'key_suspended' => "แสดงรายการขายที่พักไว้",
'key_system' => "ทางลัดระบบ",
'key_tendered' => "แก้ไขจำนวนเงินรับมา",
'key_title' => "ทางลัดคียบอร์ดงานขาย",
'mc' => "",
'mode' => "รูปแบบการลงทะเบียน",
'must_enter_numeric' => "จำนวนที่ถุกประมูลต้องใส่ข้อมุลที่เปนตัวเลข",
'must_enter_numeric_giftcard' => "เลขที่บัตรของขวัญ ต้องใส่ตัวเลขเท่านั้น",
'new_customer' => "ลูกค้าใหม่",
'new_item' => "สินค้าใหม่",
'no_description' => "ไม่ระบุรายละเอียด",
'no_filter' => "ทั้งหมด",
'no_items_in_cart' => "ไม่พบสินค้าในตระกร้า",
'no_sales_to_display' => "ไม่มีการขายที่จะแสดง",
'none_selected' => "คุณยังไม่ได้เลือกการขายที่จะลบ",
'nontaxed_ind' => " . ",
'not_authorized' => "การกระทำนี้ไม่ได้รับอนุญาต",
'one_or_multiple' => "การขาย",
'payment' => "รูปแบบชำระเงิน",
'payment_amount' => "จำนวน",
'payment_not_cover_total' => "จำนวนเงินที่ชำระต้องมากกว่าหรือเท่ากับยอดรวม",
'payment_type' => "ชำระโดย",
'payments' => "",
'payments_total' => "ยอดชำระแล้ว",
'price' => "ราคา",
'print_after_sale' => "พิมพ์บิลหลังการขาย",
'quantity' => "จำนวน",
'quantity_less_than_reorder_level' => "คำเตือน ถ้าจำนวนของไม่เพียงพอกับความต้องการหรือไม่ตรงกับยอดในบันชี ก็สามารถทำการขายได้ แต่ต้องเชคปริมานสินค้าคงคลัง",
'quantity_less_than_zero' => "คำเตือน: ถ้าจำนวนของไม่เพียงพอกับความต้องการหรือไม่ตรงกับยอดในบัญชี ก็สามารถทำการขายได้ แต่ต้องตรวจสอบปริมาญสินค้าคงคลังก่อน",
'quantity_of_items' => "ปริมาณของ {0} รายการ",
'quote' => "ใบเสนอราคา",
'quote_number' => "หมายเลขอ้างอิง",
'quote_number_duplicate' => "หมายเลขอ้างอิงต้องไม่ซ้ำกัน",
'quote_sent' => "ส่งการอ้างอิงถึง",
'quote_unsent' => "ส่งการอ้างอิงถึงผิดพลาด",
'receipt' => "บิลขาย",
'receipt_no_email' => "ลูกค้านี้ไม่มีที่อยู่อีเมล์",
'receipt_number' => "จุดขาย#",
'receipt_sent' => "ส่งใบเสร็จไปที่",
'receipt_unsent' => "ไม่สามารถส่งใบเสร็จไปที่",
'refund' => "ประเภทการยกเลิกการขาย",
'register' => "ลงทะเบียนขาย",
'remove_customer' => "ลบลูกค้า",
'remove_discount' => "",
'return' => "คืน",
'rewards' => "คะแนนสะสม",
'rewards_balance' => "คะแนนสะสมคงเหลือ",
'sale' => "ขาย",
'sale_by_invoice' => "การขายโดยใบแจ้งหนี้",
'sale_for_customer' => "ลูกค้า:",
'sale_time' => "เวลา",
'sales_tax' => "ภาษีการขาย",
'sales_total' => "",
'select_customer' => "เลือกลูกค้า (Optional)",
'send_invoice' => "ส่งใบแจ้งหนี้",
'send_quote' => "ส่งใบเสนอราคา",
'send_receipt' => "ส่งใบเสร็จ",
'send_work_order' => "ส่งคำสั่งงาน",
'serial' => "หมายเลขซีเรียล",
'service_charge' => "",
'show_due' => "",
'show_invoice' => "ใบแจ้งหนี้",
'show_receipt' => "ใบเสร็จ",
'start_typing_customer_name' => "เริ่มต้นพิมพ์ชื่อลูกค้า...",
'start_typing_item_name' => "เริ่มต้นพิมพ์ชื่อสินค้า หรือ สแกนบาร์โค๊ด...",
'stock' => "คลังสินค้า",
'stock_location' => "ที่เก็บ",
'sub_total' => "ยอดรวมย่อย",
'successfully_deleted' => "ลบการขายสมยูรณ์",
'successfully_restored' => "คุณกู้คืนสำเร็จแล้ว",
'successfully_suspended_sale' => "การขายของคุณถูกระงับเรียบร้อย",
'successfully_updated' => "อัพเดทการขายสมบูรณ์",
'suspend_sale' => "พักรายการ",
'suspended_doc_id' => "รหัสเอกสาร",
'suspended_sale_id' => "รหัสการขายที่ถูกพัก",
'suspended_sales' => "การขายที่พักไว้",
'table' => "โต๊ะ",
'takings' => "การขายประจำวัน",
'tax' => "ภาษี",
'tax_id' => "รหัสภาษี",
'tax_invoice' => "ใบกำกับภาษี",
'tax_percent' => "ภาษี %",
'taxed_ind' => "",
'total' => "ยอดรวม",
'total_tax_exclusive' => "ยอดไม่รวมภาษี",
'transaction_failed' => "การดำเนินการขายล้มเหลว",
'unable_to_add_item' => "เพิ่มรายการไปยังการขายล้มเหลว",
'unsuccessfully_deleted' => "ลบการขายไม่สำเร็จ",
'unsuccessfully_restored' => "การคืนค่ารายการขายล้มเหลว",
'unsuccessfully_suspended_sale' => "การขายของคุณถูกระงับเรียบร้อย",
'unsuccessfully_updated' => "อัพเดทการขายไม่สมบูรณ์",
'unsuspend' => "ยกเลิกการระงับ",
'unsuspend_and_delete' => "ยกเลิกการระงับ และ ลบ",
'update' => "แก้ไข",
'upi' => "ยูพีไอ",
'visa' => "",
'wholesale' => "",
'work_order' => "คำสั่งงาน",
'work_order_number' => "หมายเลขคำสั่งงาน",
'work_order_number_duplicate' => "หมายเลขคำสั่งงานต้องไม่ซ้ำกัน",
'work_order_sent' => "คำสั่งงานส่งถึง",
'work_order_unsent' => "ส่งคำสั่งงานล้มเหลว",
'selected_customer' => "ลูกค้าที่เลือก",
];

View File

@@ -2,7 +2,6 @@
namespace App\Libraries;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Database\MigrationRunner;
use Config\Database;
use stdClass;
@@ -44,7 +43,9 @@ class MY_Migration extends MigrationRunner
$result = $builder->get()->getRow();
return $result ? $result->version : 0;
}
} catch (DatabaseException $e) {
} catch (\Exception $e) {
// Database not available yet (e.g. fresh install before schema).
// Catches mysqli_sql_exception which is not a DatabaseException.
return 0;
}
@@ -76,8 +77,9 @@ class MY_Migration extends MigrationRunner
$result = $builder->get()->getRow();
return $result ? $result->version : false;
}
} catch (DatabaseException $e) {
// Database doesn't exist yet or connection failed
} catch (\Exception $e) {
// Database not available yet (e.g. fresh install before schema).
// Catches mysqli_sql_exception which is not a DatabaseException.
}
return false;

View File

@@ -23,6 +23,19 @@ use ReflectionException;
*/
class Sale_lib
{
private const KEY_SHORTCUT_DEFAULTS = [
'cancel' => ['value' => '27 | ESC', 'code' => 27, 'label' => 'ESC'],
'items' => ['value' => '49 | ALT + 1', 'code' => 49, 'label' => 'ALT + 1'],
'customers' => ['value' => '50 | ALT + 2', 'code' => 50, 'label' => 'ALT + 2'],
'suspend' => ['value' => '51 | ALT + 3', 'code' => 51, 'label' => 'ALT + 3'],
'suspended' => ['value' => '52 | ALT + 4', 'code' => 52, 'label' => 'ALT + 4'],
'amount' => ['value' => '53 | ALT + 5', 'code' => 53, 'label' => 'ALT + 5'],
'payment' => ['value' => '54 | ALT + 6', 'code' => 54, 'label' => 'ALT + 6'],
'complete' => ['value' => '55 | ALT + 7', 'code' => 55, 'label' => 'ALT + 7'],
'finish' => ['value' => '56 | ALT + 8', 'code' => 56, 'label' => 'ALT + 8'],
'help' => ['value' => '57 | ALT + 9', 'code' => 57, 'label' => 'ALT + 9'],
];
private Attribute $attribute;
private Customer $customer;
private Dinner_table $dinner_table;
@@ -95,6 +108,11 @@ class Sale_lib
'custom_tax_invoice'
];
private const ALLOWED_RECEIPT_TEMPLATES = [
'receipt_default',
'receipt_short'
];
public function get_invoice_type_options(): array
{
$invoice_types = [];
@@ -105,11 +123,54 @@ class Sale_lib
return $invoice_types;
}
/**
* Returns the available keyboard shortcut choices for the configuration screen.
*
* @return array<string, string>
*/
public function getKeyShortcutsOptions(): array
{
$keyShortcuts = [];
foreach (self::KEY_SHORTCUT_DEFAULTS as $shortcut) {
$keyShortcuts[$shortcut['value']] = $shortcut['label'];
}
return $keyShortcuts;
}
/**
* Returns parsed shortcut bindings from app_config with sensible defaults.
*
* @return array<string, array{value:string,code:int,label:string}>
*/
public function getKeyShortcuts(): array
{
$keyboardShortcuts = [];
foreach (self::KEY_SHORTCUT_DEFAULTS as $name => $default) {
$value = $this->config["key_$name"] ?? $default['value'];
$parts = array_map('trim', explode('|', $value, 2));
$keyboardShortcuts[$name] = [
'value' => $value,
'code' => (int)($parts[0] ?? $default['code']),
'label' => $parts[1] ?? $default['label']
];
}
return $keyboardShortcuts;
}
public static function isValidInvoiceType(string $invoice_type): bool
{
return in_array($invoice_type, self::ALLOWED_INVOICE_TYPES, true);
}
public static function isValidReceiptTemplate(string $receipt_template): bool
{
return in_array($receipt_template, self::ALLOWED_RECEIPT_TEMPLATES, true);
}
/**
* @return array
*/

View File

@@ -27,14 +27,12 @@ class Attribute extends Model
'definition_type',
'definition_unit',
'definition_flags',
'person_attribute',
'deleted',
'attribute_id',
'definition_id',
'item_id',
'sale_id',
'receiving_id',
'person_id',
'attribute_value',
'attribute_date',
'attribute_decimal'
@@ -43,12 +41,7 @@ class Attribute extends Model
public const SHOW_IN_ITEMS = 1; // TODO: These need to be moved to constants.php
public const SHOW_IN_SALES = 2;
public const SHOW_IN_RECEIVINGS = 4;
public const SHOW_IN_SEARCH = 8;
public const SHOW_IN_CUSTOMERS = 16;
public const SHOW_IN_EMPLOYEES = 32;
public const SHOW_IN_SUPPLIERS = 64;
public function deleteDropdownAttributeValue(string $attributeValue, int $definitionId): bool
public function deleteDropdownAttributeValue(string $attribute_value, int $definition_id): bool
{
$attribute_id = $this->getAttributeIdByValue($attribute_value);
$this->deleteAttributeLinksByDefinitionIdAndAttributeId($definition_id, $attribute_id);
@@ -276,7 +269,7 @@ class Attribute extends Model
public function get_definitions_by_flags(int $definition_flags, bool $include_types = false): array
{
$builder = $this->db->table('attribute_definitions');
$builder->where(new RawSql("definition_flags & $definition_flags"));
$builder->where(new RawSql("definition_flags & $definition_flags")); // TODO: we need to heed CI warnings to escape properly
$builder->where('deleted', 0);
$builder->where('definition_type <>', GROUP);
$builder->orderBy('definition_id');
@@ -298,30 +291,11 @@ class Attribute extends Model
}
/**
* Gets attribute definitions filtered by type (person or item)
* Returns an array of attribute definition names and IDs
*
* @param bool $isPersonAttribute True for person attributes, false for item attributes
* @param int $definitionFlags Optional visibility flags to further filter
* @return array
* @param boolean $groups If false does not return GROUP type attributes in the array
* @return array Array containing definition IDs, attribute names and -1 index with the local language '[SELECT]' line.
*/
public function getDefinitionsByType(bool $isPersonAttribute, int $definitionFlags = 0): array
{
$builder = $this->db->table('attribute_definitions');
$builder->where('person_attribute', $isPersonAttribute ? 1 : 0);
$builder->where('deleted', 0);
$builder->where('definition_type <>', GROUP);
if ($definitionFlags > 0) {
$builder->where(new RawSql("definition_flags & $definitionFlags"));
}
$builder->orderBy('definition_name', 'ASC');
$results = $builder->get()->getResultArray();
return $this->to_array($results, 'definition_id', 'definition_name');
}
public function get_definition_names(bool $groups = true): array
{
$builder = $this->db->table('attribute_definitions');
@@ -627,6 +601,10 @@ class Attribute extends Model
*/
public function saveAttributeLink(int $itemId, int $definitionId, int $attributeId): bool
{
if ($attributeId <= 0) {
return false;
}
$normalizedItemId = empty($itemId) ? null : $itemId;
$normalizedAttributeId = empty($attributeId) ? null : $attributeId;
@@ -1253,227 +1231,4 @@ class Attribute extends Model
$itemsBuilder->update();
}
}
/**
* Gets all attributes connected to a person given the person_id
*
* @param int $personId Person to retrieve attributes for.
* @return array Attributes for the person.
*/
public function getAttributesByPerson(int $personId): array
{
$builder = $this->db->table('attribute_definitions');
$builder->join('attribute_links', 'attribute_links.definition_id = attribute_definitions.definition_id');
$builder->where('person_id', $personId);
$builder->where('item_id', null);
$builder->where('sale_id', null);
$builder->where('receiving_id', null);
$builder->where('deleted', 0);
$builder->orderBy('definition_name', 'ASC');
$results = $builder->get()->getResultArray();
return $this->to_array($results, 'definition_id');
}
/**
* Returns whether an attribute_link row exists given a person_id and optionally a definition_id
*
* @param int $personId ID of the person to check for an associated attribute.
* @param int|bool $definitionId Attribute definition ID to check.
* @return bool Returns true if at least one attribute_link exists or false if no attributes exist for that person and attribute.
*/
public function personAttributeLinkExists(int $personId, int|bool $definitionId = false): bool
{
$builder = $this->db->table('attribute_links');
$builder->where('person_id', $personId);
$builder->where('item_id', null);
$builder->where('sale_id', null);
$builder->where('receiving_id', null);
if ($definitionId) {
$builder->where('definition_id', $definitionId);
} else {
$builder->where('definition_id IS NOT NULL');
$builder->where('attribute_id', null);
}
$results = $builder->countAllResults();
return $results > 0;
}
/**
* Inserts or updates an attribute link for a person
*
* @param int $personId
* @param int $definitionId
* @param int $attributeId
* @return bool True if the attribute link was saved successfully, false otherwise.
*/
public function savePersonAttributeLink(int $personId, int $definitionId, int $attributeId): bool
{
$this->db->transStart();
$builder = $this->db->table('attribute_links');
if ($this->personAttributeLinkExists($personId, $definitionId)) {
$builder->set(['attribute_id' => $attributeId]);
$builder->where('definition_id', $definitionId);
$builder->where('person_id', $personId);
$builder->where('item_id', null);
$builder->where('sale_id', null);
$builder->where('receiving_id', null);
$builder->update();
} else {
$data = [
'attribute_id' => $attributeId,
'person_id' => $personId,
'definition_id' => $definitionId
];
$builder->insert($data);
}
$this->db->transComplete();
return $this->db->transStatus();
}
/**
* Deletes attribute links for a person
*
* @param int $personId
* @param int|bool $definitionId
* @return bool
*/
public function deletePersonAttributeLinks(int $personId, int|bool $definitionId = false): bool
{
$deleteData = ['person_id' => $personId];
$builder = $this->db->table('attribute_links');
$builder->where('item_id', null);
$builder->where('sale_id', null);
$builder->where('receiving_id', null);
if (!empty($definitionId)) {
$deleteData['definition_id'] = $definitionId;
}
return $builder->delete($deleteData);
}
/**
* Gets the attribute value for a person and definition
*
* @param int $personId
* @param int $definitionId
* @return object|null
*/
public function getPersonAttributeValue(int $personId, int $definitionId): ?object
{
$builder = $this->db->table('attribute_values');
$builder->join('attribute_links', 'attribute_links.attribute_id = attribute_values.attribute_id');
$builder->where('person_id', $personId);
$builder->where('item_id', null);
$builder->where('sale_id', null);
$builder->where('receiving_id', null);
$builder->where('definition_id', $definitionId);
$query = $builder->get();
if ($query->getNumRows() == 1) {
return $query->getRow();
}
return $this->getEmptyObject('attribute_values');
}
/**
* Saves an attribute value for a person
*
* @param string $attributeValue
* @param int $definitionId
* @param int $personId
* @param int|bool $attributeId
* @param string $definitionType
* @return int
*/
public function savePersonAttributeValue(string $attributeValue, int $definitionId, int $personId, int|bool $attributeId = false, string $definitionType = DROPDOWN): int
{
$config = config(OSPOS::class)->settings;
$this->db->transStart();
switch ($definitionType) {
case DATE:
$dataType = 'date';
$attributeDateValue = DateTime::createFromFormat($config['dateformat'], $attributeValue);
$attributeValue = $attributeDateValue ? $attributeDateValue->format('Y-m-d') : $attributeValue;
break;
case DECIMAL:
$dataType = 'decimal';
break;
default:
$dataType = 'value';
break;
}
// New Attribute
if (empty($attributeId) || empty($personId) || $attributeId == -1) {
$attributeId = $this->attributeValueExists($attributeValue, $definitionType);
if (!$attributeId) {
$builder = $this->db->table('attribute_values');
$builder->set(["attribute_$dataType" => $attributeValue]);
$builder->insert();
$attributeId = $this->db->insertID();
}
$data = [
'attribute_id' => empty($attributeId) ? null : $attributeId,
'person_id' => $personId,
'definition_id' => $definitionId
];
$builder = $this->db->table('attribute_links');
$builder->set($data);
$builder->insert();
}
// Existing Attribute
else {
$builder = $this->db->table('attribute_values');
$builder->set(["attribute_$dataType" => $attributeValue]);
$builder->where('attribute_id', $attributeId);
$builder->update();
}
$this->db->transComplete();
return $attributeId;
}
/**
* Gets link values for a person given the person_id and visibility flags
*
* @param int $personId
* @param int $definitionFlags
* @return ResultInterface
*/
public function getPersonLinkValues(int $personId, int $definitionFlags): ResultInterface
{
$format = $this->db->escape(dateformat_mysql());
$builder = $this->db->table('attribute_links');
$builder->select("GROUP_CONCAT(attribute_value SEPARATOR ', ') AS attribute_values");
$builder->select("GROUP_CONCAT(DATE_FORMAT(attribute_date, $format) SEPARATOR ', ') AS attribute_dtvalues");
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
$builder->join('attribute_definitions', 'attribute_definitions.definition_id = attribute_links.definition_id');
$builder->where('definition_type <>', GROUP);
$builder->where('deleted', ACTIVE);
$builder->where('person_id', $personId);
$builder->where('item_id', null);
$builder->where('sale_id', null);
$builder->where('receiving_id', null);
$builder->where(new RawSql("definition_flags & $definitionFlags"));
return $builder->get();
}
}

View File

@@ -391,7 +391,6 @@ class Item extends Model
public function get_item_id(string $item_number, bool $ignore_deleted = false, bool $deleted = false): bool|int
{
$builder = $this->db->table('items');
$builder->join('suppliers', 'suppliers.person_id = items.supplier_id', 'left');
$builder->groupStart();
$builder->where('item_number', $item_number);
$builder->orWhere('item_id', $item_number);

View File

@@ -294,7 +294,9 @@ class Receiving extends Model
lang('Sales.check') => lang('Sales.check'),
lang('Sales.debit') => lang('Sales.debit'),
lang('Sales.credit') => lang('Sales.credit'),
lang('Sales.due') => lang('Sales.due')
lang('Sales.due') => lang('Sales.due'),
lang('Sales.bank_transfer') => lang('Sales.bank_transfer'),
lang('Sales.wallet') => lang('Sales.wallet')
];
}

View File

@@ -33,14 +33,16 @@ class Summary_sales_taxes extends Summary_report
* @param object $builder
* @return void
*/
protected function _where(array $inputs, object &$builder): void // TODO: hungarian notation
protected function _where(array $inputs, object &$builder): void
{
$builder->where('sales.sale_status', COMPLETED);
if (empty($this->config['date_or_time_format'])) { // TODO: Duplicated code
$builder->where('DATE(sales.sale_time) BETWEEN ' . $this->db->escape($inputs['start_date']) . ' AND ' . $this->db->escape($inputs['end_date']));
if (empty($this->config['date_or_time_format'])) {
$builder->where('DATE(sales.sale_time) >=', $inputs['start_date']);
$builder->where('DATE(sales.sale_time) <=', $inputs['end_date']);
} else {
$builder->where('sales.sale_time BETWEEN ' . $this->db->escape(rawurldecode($inputs['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($inputs['end_date'])));
$builder->where('sales.sale_time >=', $inputs['start_date']);
$builder->where('sales.sale_time <=', $inputs['end_date']);
}
}
@@ -53,9 +55,11 @@ class Summary_sales_taxes extends Summary_report
$builder = $this->db->table('sales_taxes');
if (empty($this->config['date_or_time_format'])) {
$builder->where('DATE(sale_time) BETWEEN ' . $inputs['start_date'] . ' AND ' . $inputs['end_date']);
$builder->where('DATE(sale_time) >=', $inputs['start_date']);
$builder->where('DATE(sale_time) <=', $inputs['end_date']);
} else {
$builder->where('sale_time BETWEEN ' . $this->db->escape(rawurldecode($inputs['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($inputs['end_date'])));
$builder->where('sale_time >=', $inputs['start_date']);
$builder->where('sale_time <=', $inputs['end_date']);
}
$builder->select('reporting_authority, jurisdiction_name, tax_category, tax_rate, SUM(sale_tax_amount) AS tax');

View File

@@ -277,6 +277,14 @@ class Sale extends Model
$builder->like('payment_type', lang('Sales.debit'));
}
if ($filters['only_bank_transfer']) {
$builder->like('payment_type', lang('Sales.bank_transfer'));
}
if ($filters['only_wallet']) {
$builder->like('payment_type', lang('Sales.wallet'));
}
$builder->groupBy('payment_type');
$payments = $builder->get()->getResultArray();
@@ -1509,5 +1517,13 @@ class Sale extends Model
if ($filters['only_check']) {
$builder->like('payments.payment_type', lang('Sales.check'));
}
if ($filters['only_bank_transfer']) {
$builder->like('payments.payment_type', lang('Sales.bank_transfer'));
}
if ($filters['only_wallet']) {
$builder->like('payments.payment_type', lang('Sales.wallet'));
}
}
}

View File

@@ -1,169 +0,0 @@
<?php
/**
* @var array $definition_names
* @var array $definition_values
* @var int $person_id
* @var array $config
*/
use App\Models\Attribute;
?>
<div class="form-group form-group-sm">
<?= form_label(lang('Attributes.definition_name'), 'definition_name_label', ['class' => 'control-label col-xs-3']) ?>
<div class="col-xs-8">
<?= form_dropdown([
'name' => 'definition_name',
'options' => $definition_names,
'selected' => -1,
'class' => 'form-control',
'id' => 'definition_name'
]) ?>
</div>
</div>
<?php foreach ($definition_values as $definitionId => $definitionValue) { ?>
<div class="form-group form-group-sm">
<?= form_label(esc($definitionValue['definition_name']), esc($definitionValue['definition_name']), ['class' => 'control-label col-xs-3']) ?>
<div class="col-xs-8">
<div class="input-group">
<?php
echo form_hidden("attribute_ids[$definitionId]", strval($definitionValue['attribute_id']));
$attributeValue = $definitionValue['attribute_value'];
switch ($definitionValue['definition_type']) {
case DATE:
$value = (empty($attributeValue) || empty($attributeValue->attribute_date)) ? NOW : strtotime($attributeValue->attribute_date);
echo form_input([
'name' => "attribute_links[$definitionId]",
'value' => to_date($value),
'class' => 'form-control input-sm datetime',
'data-definition-id' => $definitionId,
'readonly' => 'true'
]);
break;
case DROPDOWN:
$selectedValue = $definitionValue['selected_value'];
echo form_dropdown([
'name' => "attribute_links[$definitionId]",
'options' => $definitionValue['values'],
'selected' => $selectedValue,
'class' => 'form-control',
'data-definition-id' => $definitionId
]);
break;
case TEXT:
$value = (empty($attributeValue) || empty($attributeValue->attribute_value)) ? $definitionValue['selected_value'] : $attributeValue->attribute_value;
echo form_input([
'name' => "attribute_links[$definitionId]",
'value' => esc($value),
'class' => 'form-control valid_chars',
'data-definition-id' => $definitionId
]);
break;
case DECIMAL:
$value = (empty($attributeValue) || empty($attributeValue->attribute_decimal)) ? $definitionValue['selected_value'] : $attributeValue->attribute_decimal;
echo form_input([
'name' => "attribute_links[$definitionId]",
'value' => to_decimals((float)$value),
'class' => 'form-control valid_chars',
'data-definition-id' => $definitionId
]);
break;
case CHECKBOX:
$value = (empty($attributeValue) || empty($attributeValue->attribute_value)) ? $definitionValue['selected_value'] : $attributeValue->attribute_value;
// Sends 0 if the box is unchecked instead of not sending anything.
echo form_input([
'type' => 'hidden',
'name' => "attribute_links[$definitionId]",
'id' => "attribute_links[$definitionId]",
'value' => 0,
'data-definition-id' => $definitionId
]);
echo form_checkbox([
'name' => "attribute_links[$definitionId]",
'id' => "attribute_links[$definitionId]",
'value' => 1,
'checked' => $value == 1,
'class' => 'checkbox-inline',
'data-definition-id' => $definitionId
]);
break;
}
?>
<span class="input-group-addon input-sm btn btn-default remove_attribute_btn">
<span class="glyphicon glyphicon-trash"></span>
</span>
</div>
</div>
</div>
<?php } ?>
<script type="text/javascript">
(function() {
<?= view('partial/datepicker_locale', ['format' => dateformat_bootstrap($config['dateformat'])]) ?>
var enableDelete = function() {
$('.remove_attribute_btn').click(function() {
$(this).parents('.form-group').remove();
});
};
enableDelete();
$("input[name*='attribute_links']").change(function() {
var definitionId = $(this).data('definition-id');
$("input[name='attribute_ids[" + definitionId + "]']").val('');
}).autocomplete({
source: function(request, response) {
$.get('<?= 'attributes/suggestAttribute/' ?>' + this.element.data('definition-id') + '?term=' + request.term, function(data) {
return response(data);
}, 'json');
},
appendTo: '.modal-content',
select: function(event, ui) {
event.preventDefault();
$(this).val(ui.item.label);
},
delay: 10
});
var getDefinitionValues = function() {
var result = {};
$("[name*='attribute_links']").each(function() {
var definitionId = $(this).data('definition-id');
var element = $(this);
// For checkboxes, use the visible checkbox, not the hidden input
if (element.attr('type') === 'hidden' && element.siblings('input[type="checkbox"]').length > 0) {
// Skip hidden inputs that have a corresponding checkbox
return;
}
// For checkboxes, get the checked state
if (element.attr('type') === 'checkbox') {
result[definitionId] = element.prop('checked') ? '1' : '0';
} else {
result[definitionId] = element.val();
}
});
return result;
};
var refresh = function() {
var definitionId = $("#definition_name option:selected").val();
var attributeValues = getDefinitionValues();
attributeValues[definitionId] = '';
$('#person_attributes').load(window.location.href, {
'definition_ids': JSON.stringify(attributeValues)
}, enableDelete);
};
$('#definition_name').change(function() {
refresh();
});
})();
</script>

View File

@@ -11,31 +11,34 @@ $barcode_lib = new Barcode_lib();
<!doctype html>
<html lang="<?= current_language_code() ?>">
<head>
<meta charset="utf-8">
<title><?= esc(lang('Items.generate_barcodes')) ?></title>
<link rel="stylesheet" href="<?= esc(base_url('css/barcode_font.css'), 'url') ?>">
<style>
.barcode svg {
height: <?= (int) $barcode_config['barcode_height'] ?>px;
width: <?= (int) $barcode_config['barcode_width'] ?>px;
}
</style>
</head>
<body class="<?= esc('font_' . $barcode_lib->get_font_name($barcode_config['barcode_font']), 'attr') ?>" style="font-size: <?= (int) $barcode_config['barcode_font_size'] ?>px;">
<table style="border-spacing: <?= (int) $barcode_config['barcode_page_cellspacing'] ?>px; width: <?= (int) $barcode_config['barcode_page_width'] ?>%;">
<tr>
<?php
$count = 0;
foreach ($items as $item) {
if ($count % $barcode_config['barcode_num_in_row'] == 0 && $count != 0) {
echo '</tr><tr>';
}
echo '<td>' . $barcode_lib->display_barcode($item, $barcode_config) . '</td>';
$count++;
<head>
<meta charset="utf-8">
<title><?= esc(lang('Items.generate_barcodes')) ?></title>
<link rel="stylesheet" href="<?= esc(base_url('css/barcode_font.css'), 'url') ?>">
<style>
.barcode svg {
height: <?= (int) $barcode_config['barcode_height'] ?>px;
width: <?= (int) $barcode_config['barcode_width'] ?>px;
}
</style>
</head>
<body class="<?= esc('font_' . $barcode_lib->get_font_name($barcode_config['barcode_font']), 'attr') ?>" style="font-size: <?= (int) $barcode_config['barcode_font_size'] ?>px;">
<table style="border-spacing: <?= (int) $barcode_config['barcode_page_cellspacing'] ?>px; width: <?= (int) $barcode_config['barcode_page_width'] ?>%;">
<tr>
<?php
$count = 0;
foreach ($items as $item) {
if ($count % $barcode_config['barcode_num_in_row'] == 0 && $count != 0) {
echo '</tr><tr>';
}
?>
</tr>
</table>
</body>
echo '<td>' . $barcode_lib->display_barcode($item, $barcode_config) . '</td>';
$count++;
}
?>
</tr>
</table>
</body>
</html>

View File

@@ -204,6 +204,7 @@
<?= form_label(lang('Config.barcode_number_in_row'), 'barcode_num_in_row', ['class' => 'control-label col-xs-2 required']) ?>
<div class="col-xs-2">
<?= form_input([
'type' => 'number',
'name' => 'barcode_num_in_row',
'id' => 'barcode_num_in_row',
'class' => 'form-control input-sm required',
@@ -217,6 +218,9 @@
<div class="col-sm-2">
<div class="input-group">
<?= form_input([
'type' => 'number',
'min' => '0',
'max' => '100',
'name' => 'barcode_page_width',
'id' => 'barcode_page_width',
'class' => 'form-control input-sm required',
@@ -232,6 +236,7 @@
<div class="col-sm-2">
<div class="input-group">
<?= form_input([
'type' => 'number',
'name' => 'barcode_page_cellspacing',
'id' => 'barcode_page_cellspacing',
'class' => 'form-control input-sm required',

View File

@@ -17,9 +17,9 @@
<?= form_dropdown(
'protocol',
[
'mail' => 'mail',
'sendmail' => 'sendmail',
'smtp' => 'smtp'
'mail' => 'Mail',
'sendmail' => 'Sendmail',
'smtp' => 'SMTP'
],
$config['protocol'],
'class="form-control input-sm" id="protocol"'
@@ -55,6 +55,7 @@
<?= form_label(lang('Config.email_smtp_port'), 'smtp_port', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-2">
<?= form_input([
'type' => 'number',
'name' => 'smtp_port',
'id' => 'smtp_port',
'class' => 'form-control input-sm',
@@ -83,6 +84,7 @@
<?= form_label(lang('Config.email_smtp_timeout'), 'smtp_timeout', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-2">
<?= form_input([
'type' => 'number',
'name' => 'smtp_timeout',
'id' => 'smtp_timeout',
'class' => 'form-control input-sm',

View File

@@ -105,6 +105,7 @@
<span class="glyphicon glyphicon-phone-alt"></span>
</span>
<?= form_input([
'type' => 'tel',
'name' => 'phone',
'id' => 'phone',
'class' => 'form-control input-sm required',
@@ -122,6 +123,7 @@
<span class="glyphicon glyphicon-phone-alt"></span>
</span>
<?= form_input([
'type' => 'tel',
'name' => 'fax',
'id' => 'fax',
'class' => 'form-control input-sm',

View File

@@ -29,6 +29,9 @@
<li role="presentation">
<a data-toggle="tab" href="#invoice_tab" title="<?= lang('Config.invoice_configuration') ?>"><?= lang('Config.invoice') ?></a>
</li>
<li role="presentation">
<a data-toggle="tab" href="#shortcuts_tab" title="<?= lang('Config.shortcuts_configuration') ?>"><?= lang('Config.shortcuts') ?></a>
</li>
<li role="presentation">
<a data-toggle="tab" href="#reward_tab" title="<?= lang('Config.reward_configuration') ?>"><?= lang('Config.reward') ?></a>
</li>
@@ -65,6 +68,9 @@
<div class="tab-pane" id="invoice_tab">
<?= view('configs/invoice_config') ?>
</div>
<div class="tab-pane" id="shortcuts_tab">
<?= view('configs/shortcuts_config') ?>
</div>
<div class="tab-pane" id="reward_tab">
<?= view('configs/reward_config') ?>
</div>

View File

@@ -0,0 +1,88 @@
<?php
/**
* @var array $config
* @var array $keyboardShortcutOptions
* @var array $keyboardShortcuts
*/
$keyboardShortcuts ??= [];
$keyboardShortcutOptions ??= [];
$config ??= [];
$shortcutLabels = [
'cancel' => lang('Sales.key_cancel'),
'items' => lang('Sales.key_item_search'),
'customers' => lang('Sales.key_customer_search'),
'suspend' => lang('Sales.key_suspend'),
'suspended' => lang('Sales.key_suspended'),
'amount' => lang('Sales.key_tendered'),
'payment' => lang('Sales.key_payment'),
'complete' => lang('Sales.key_finish_sale'),
'finish' => lang('Sales.key_finish_quote'),
'help' => lang('Sales.key_help_modal')
];
?>
<?= form_open('config/saveShortcuts', ['id' => 'shortcuts_config_form', 'class' => 'form-horizontal']) ?>
<div id="config_wrapper">
<div class="row">
<fieldset id="config_info">
<div class="col-md-8">
<div id="required_fields_message"><?= esc(lang('Common.fields_required_message')) ?></div>
<ul id="shortcuts_error_message_box" class="error_message_box"></ul>
<?php foreach ($shortcutLabels as $name => $label): ?>
<div class="form-group form-group-sm">
<?= form_label($label, 'key_' . $name, ['class' => 'control-label col-xs-3']) ?>
<div class="col-xs-4">
<?php $keyboardShortcutSelectedValue = $keyboardShortcuts[$name]['value'] ?? ''; ?>
<?= form_dropdown(
'key_' . $name,
$keyboardShortcutOptions,
$keyboardShortcutSelectedValue,
'class="form-control input-sm"'
) ?>
</div>
</div>
<?php endforeach; ?>
<div class="col-xs-12 clearfix">
<?= form_submit([
'name' => 'submit_shortcuts',
'id' => 'submit_shortcuts',
'value' => lang('Common.submit'),
'class' => 'btn btn-primary btn-sm pull-right'
]) ?>
</div>
</div>
</fieldset>
</div>
</div>
<?= form_close() ?>
<script type="text/javascript">
$('#shortcuts_config_form').validate($.extend(form_support.handler, {
submitHandler: function(form) {
$(form).ajaxSubmit({
success: function(response) {
$.notify({
message: response.message
}, {
type: response.success ? 'success' : 'danger'
});
},
error: function(xhr) {
const rawMessage = xhr.responseJSON?.message ?? xhr.responseText ?? <?= json_encode(lang('Config.shortcuts_save_error')) ?>;
$.notify({
message: DOMPurify.sanitize(rawMessage)
}, {
type: 'danger'
});
},
dataType: 'json'
});
},
errorLabelContainer: '#shortcuts_error_message_box'
}));
</script>

View File

@@ -25,8 +25,8 @@ use Config\OSPOS;
<div class="container">
<div class="row">
<div class="col-sm-2" style="text-align: left;"><br>
<p style="min-height: 14.7em; font-weight: bold;">General Info</p>
<p style="min-height: 10.5em; font-weight: bold;">User Setup</p><br>
<p style="min-height: 17.7em; font-weight: bold;">General Info</p>
<p style="min-height: 12.2em; font-weight: bold;">User Setup</p><br>
<p style="font-weight: bold;">Permissions</p>
</div>
<div class="col-sm-8" id="issuetemplate" style="text-align: left;"><br>
@@ -42,7 +42,7 @@ use Config\OSPOS;
echo "&#187; OpenSSL: ", extension_loaded('openssl') ? '<span style="color: green;">Enabled &#x2713</span>' : '<span style="color: red;">Disabled &#x2717</span>', '<br>';
echo "&#187; MBString: ", extension_loaded('mbstring') ? '<span style="color: green;">Enabled &#x2713</span>' : '<span style="color: red;">Disabled &#x2717</span>', '<br>';
echo "&#187; Curl: ", extension_loaded('curl') ? '<span style="color: green;">Enabled &#x2713</span>' : '<span style="color: red;">Disabled &#x2717</span>', '<br>';
echo "&#187; Json: ", extension_loaded('json') ? '<span style="color: green;">Enabled &#x2713</span>' : '<span style="color: red;">Disabled &#x2717</span>', '<br><br>';
echo "&#187; Json: ", extension_loaded('json') ? '<span style="color: green;">Enabled &#x2713</span>' : '<span style="color: red;">Disabled &#x2717</span>', '<br>';
echo "&#187; Xml: ", extension_loaded('xml') ? '<span style="color: green;">Enabled &#x2713</span>' : '<span style="color: red;">Disabled &#x2717</span>', '<br><br>';
?>
User Configuration:<br>

View File

@@ -51,6 +51,10 @@
</div>
<div class="col-xs-1 input-group">
<?= form_input([
'type' => 'number',
'step' => 'any',
'min' => '0',
'max' => '100',
'name' => 'default_tax_1_rate',
'id' => 'default_tax_1_rate',
'class' => 'form-control input-sm',
@@ -72,6 +76,10 @@
</div>
<div class="col-xs-1 input-group">
<?= form_input([
'type' => 'number',
'step' => 'any',
'min' => '0',
'max' => '100',
'name' => 'default_tax_2_rate',
'id' => 'default_tax_2_rate',
'class' => 'form-control input-sm',

View File

@@ -44,12 +44,6 @@
<?= view('people/form_basic_info') ?>
<div id="person_attributes">
<script type="text/javascript">
$('#person_attributes').load('<?= "customers/attributes/$person_info->person_id" ?>');
</script>
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Customers.discount_type'), 'discount_type', ['class' => 'control-label col-xs-3']) ?>
<div class="col-xs-8">

View File

@@ -29,12 +29,6 @@
<div class="tab-pane fade in active" id="employee_basic_info">
<fieldset>
<?= view('people/form_basic_info') ?>
<div id="person_attributes">
<script type="text/javascript">
$('#person_attributes').load('<?= "employees/attributes/$person_info->person_id" ?>');
</script>
</div>
</fieldset>
</div>

View File

@@ -101,9 +101,11 @@ p.lead {
}
.tabs {
list-style: none inside none;
list-style: none;
list-style-position: inside;
margin: 0;
padding: 0;
margin: 0 0 -1px;
margin-bottom: -1px;
}
.tabs li {
display: inline;

View File

@@ -5,9 +5,14 @@
* @var bool $is_new_install
* @var string $latest_version
* @var bool $gcaptcha_enabled
* @var CodeIgniter\HTTP\IncomingRequest $request
* @var array $config
* @var $validation
*/
use Config\Services;
$request = Services::request();
?>
<!doctype html>
@@ -154,11 +159,6 @@
</div>
</footer>
<?php
use Config\Services;
$request = Services::request();
?>
<?php if (ENVIRONMENT == 'development' || get_cookie('debug') == 'true' || $request->getGet('debug') == 'true') : ?>
<!-- inject:login:debug:js -->
<!-- endinject -->

View File

@@ -12,14 +12,16 @@ $request = Services::request();
?>
<!doctype html>
<html lang="<?= $request->getLocale() ?>">
<html lang="<?= current_language_code() ?>">
<head>
<meta charset="utf-8">
<base href="<?= base_url() ?>">
<title><?= esc($config['company']) . ' | ' . lang('Common.powered_by') . ' OSPOS ' . esc(config('App')->application_version) ?></title>
<meta name="robots" content="noindex, nofollow">
<link rel="shortcut icon" type="image/x-icon" href="images/favicon.ico">
<link rel="stylesheet" href="<?= 'resources/bootswatch/' . (empty($config['theme']) ? 'flatly' : esc($config['theme'])) . '/bootstrap.min.css' ?>">
<?php $theme = (empty($config['theme']) ? 'flatly' : esc($config['theme'])); ?>
<link rel="stylesheet" href="resources/bootswatch/<?= "$theme" ?>/bootstrap.min.css">
<?php if (ENVIRONMENT == 'development' || get_cookie('debug') == 'true' || $request->getGet('debug') == 'true') : ?>
<!-- inject:debug:css -->

View File

@@ -1,3 +1,24 @@
<?php
/**
* @var array $keyboardShortcuts
*/
$keyboardShortcuts ??= [];
$shortcut_labels = [
'cancel' => lang('Sales.key_cancel'),
'items' => lang('Sales.key_item_search'),
'customers' => lang('Sales.key_customer_search'),
'suspend' => lang('Sales.key_suspend'),
'suspended' => lang('Sales.key_suspended'),
'amount' => lang('Sales.key_tendered'),
'payment' => lang('Sales.key_payment'),
'complete' => lang('Sales.key_finish_sale'),
'finish' => lang('Sales.key_finish_quote'),
'help' => lang('Sales.key_help_modal')
];
?>
<div class="container-fluid">
<ul class="nav nav-tabs" id="SCTabs" data-toggle="tab">
@@ -15,46 +36,13 @@
</tr>
</thead>
<tbody>
<tr>
<td><code>ESC</code></td>
<td><?= lang('Sales.key_cancel'); ?></td>
</tr>
<tr>
<td><code>ALT + 1</code></td>
<td><?= lang('Sales.key_item_search'); ?></td>
</tr>
<tr>
<td><code>ALT + 2</code></td>
<td><?= lang('Sales.key_customer_search'); ?></td>
</tr>
<tr>
<td><code>ALT + 3</code></td>
<td><?= lang('Sales.key_suspend'); ?></td>
</tr>
<tr>
<td><code>ALT + 4</code></td>
<td><?= lang('Sales.key_suspended'); ?></td>
</tr>
<tr>
<td><code>ALT + 5</code></td>
<td><?= lang('Sales.key_tendered'); ?></td>
</tr>
<tr>
<td><code>ALT + 6</code></td>
<td><?= lang('Sales.key_payment'); ?></td>
</tr>
<tr>
<td><code>ALT + 7</code></td>
<td><?= lang('Sales.key_finish_sale'); ?></td>
</tr>
<tr>
<td><code>ALT + 8</code></td>
<td><?= lang('Sales.key_finish_quote'); ?></td>
</tr>
<tr>
<td><code>ALT + 9</code></td>
<td><?= lang('Sales.key_help_modal'); ?></td>
</tr>
<?php foreach ($shortcut_labels as $name => $label): ?>
<?php $shortcut = $keyboardShortcuts[$name] ?? ['label' => '', 'code' => '']; ?>
<tr>
<td><code><?= esc($shortcut['label'] !== '' ? $shortcut['label'] : $shortcut['code']) ?></code></td>
<td><?= esc($label) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>

View File

@@ -15,7 +15,7 @@
?>
<!doctype html>
<html lang="<?= $this->request->getLocale() ?>">
<html lang="<?= current_language_code() ?>">
<head>
<meta charset="utf-8">

View File

@@ -2,11 +2,14 @@
/**
* @var int $sale_id_num
* @var bool $print_after_sale
* @var string $receipt_template_view
* @var array $config
*/
use App\Models\Employee;
$template = $receipt_template_view ?? 'receipt_default';
?>
<?= view('partial/header') ?>
@@ -61,6 +64,6 @@ if (isset($error_message)) {
<?php endif; ?>
</div>
<?= view('sales/' . $config['receipt_template']) ?>
<?= view('sales/' . $template) ?>
<?= view('partial/footer') ?>

View File

@@ -405,6 +405,7 @@ helper('url');
<div id="payment_details">
<?php if ($payments_cover_total) { // Show Complete sale button instead of Add Payment if there is no amount due left ?>
<?= form_open("$controller_name/addPayment", ['id' => 'add_payment_form', 'class' => 'form-horizontal']) ?>
<input type="hidden" name="complete_after_payment" value="0">
<table class="sales_table_100">
<tr>
<td><?= lang(ucfirst($controller_name) . '.payment') ?></td>
@@ -445,6 +446,7 @@ helper('url');
?>
<?php } else { ?>
<?= form_open("$controller_name/addPayment", ['id' => 'add_payment_form', 'class' => 'form-horizontal']) ?>
<input type="hidden" name="complete_after_payment" value="0">
<table class="sales_table_100">
<tr>
<td><?= lang(ucfirst($controller_name) . '.payment') ?></td>
@@ -565,6 +567,21 @@ helper('url');
</div>
<script type="text/javascript">
const keyboardShortcuts = <?= json_encode($keyboardShortcuts ?? []) ?>;
const paymentsCoverTotal = <?= json_encode((bool) $payments_cover_total) ?>;
const shortcutCodes = {
items: keyboardShortcuts?.items?.code ?? null,
customers: keyboardShortcuts?.customers?.code ?? null,
suspend: keyboardShortcuts?.suspend?.code ?? null,
suspended: keyboardShortcuts?.suspended?.code ?? null,
amount: keyboardShortcuts?.amount?.code ?? null,
payment: keyboardShortcuts?.payment?.code ?? null,
complete: keyboardShortcuts?.complete?.code ?? null,
finish: keyboardShortcuts?.finish?.code ?? null,
help: keyboardShortcuts?.help?.code ?? null,
cancel: keyboardShortcuts?.cancel?.code ?? null
};
$(document).ready(function() {
const redirect = function() {
window.location.href = "<?= site_url('sales'); ?>";
@@ -750,6 +767,7 @@ helper('url');
});
$('#add_payment_button').click(function() {
$('#add_payment_form').find('input[name="complete_after_payment"]').val('0');
$('#add_payment_form').submit();
});
@@ -839,43 +857,51 @@ helper('url');
}
// Add Keyboard Shortcuts/Hotkeys to Sale Register
document.body.onkeyup = function(e) {
switch (event.altKey && event.keyCode) {
case 49: // Alt + 1 Items Seach
$("#item").focus();
$("#item").select();
break;
case 50: // Alt + 2 Customers Search
$("#customer").focus();
$("#customer").select();
break;
case 51: // Alt + 3 Suspend Current Sale
$("#suspend_sale_button").click();
break;
case 52: // Alt + 4 Check Suspended
$("#show_suspended_sales_button").click();
break;
case 53: // Alt + 5 Edit Amount Tendered Value
$("#amount_tendered").focus();
$("#amount_tendered").select();
break;
case 54: // Alt + 6 Add Payment
$("#add_payment_button").click();
break;
case 55: // Alt + 7 Add Payment and Complete Sales/Invoice
$("#add_payment_button").click();
window.location.href = "<?= 'sales/complete' ?>";
break;
case 56: // Alt + 8 Finish Quote/Invoice without payment
$("#finish_invoice_quote_button").click();
break;
case 57: // Alt + 9 Open Shortcuts Help Modal
$("#show_keyboard_help").click();
break;
document.body.onkeyup = function(event) {
if ($(event.target).closest('.modal').length || $('.modal.in').length) {
return;
}
if (event.altKey) {
switch (event.keyCode) {
case shortcutCodes.items:
$("#item").focus();
$("#item").select();
break;
case shortcutCodes.customers:
$("#customer").focus();
$("#customer").select();
break;
case shortcutCodes.suspend:
$("#suspend_sale_button").click();
break;
case shortcutCodes.suspended:
$("#show_suspended_sales_button").click();
break;
case shortcutCodes.amount:
$("#amount_tendered").focus();
$("#amount_tendered").select();
break;
case shortcutCodes.payment:
$("#add_payment_button").click();
break;
case shortcutCodes.complete:
if (paymentsCoverTotal && $("#finish_sale_button").length) {
$("#finish_sale_button").click();
} else {
$("#add_payment_button").click();
}
break;
case shortcutCodes.finish:
$("#finish_invoice_quote_button").click();
break;
case shortcutCodes.help:
$("#show_keyboard_help").click();
break;
}
}
switch (event.keyCode) {
case 27: // ESC Cancel Current Sale
case shortcutCodes.cancel:
$("#cancel_sale_button").click();
break;
}

View File

@@ -14,7 +14,7 @@
?>
<!doctype html>
<html lang="<?= $this->request->getLocale() ?>">
<html lang="<?= current_language_code() ?>">
<head>
<meta charset="utf-8">

View File

@@ -45,12 +45,6 @@
<?= view('people/form_basic_info') ?>
<div id="person_attributes">
<script type="text/javascript">
$('#person_attributes').load('<?= "suppliers/attributes/$person_info->person_id" ?>');
</script>
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Suppliers.account_number'), 'account_number', ['class' => 'control-label col-xs-3']) ?>
<div class="col-xs-8">

View File

@@ -2,25 +2,19 @@
"name": "opensourcepos/opensourcepos",
"description": "Open Source Point of Sale is a web based POS system written in the PHP language. It uses MySQL as backend and has a simple user interface",
"license": "MIT",
"type": "project",
"keywords": [
"point-of-sale",
"POS"
],
"authors": [
{
"name": "jekkos"
},
{
"name": "FrancescoUK"
},
{
"name": "objecttothis"
},
{
"name": "steveireland"
}
],
"type": "project",
"keywords": [
"point-of-sale",
"POS"
],
"homepage": "https://opensourcepos.org",
"support": {
"issues": "https://github.com/opensourcepos/opensourcepos/issues",
@@ -31,8 +25,8 @@
"matrix": "https://matrix.to/#/#opensourcepos_Lobby:gitter.im"
},
"require": {
"ext-intl": "*",
"php": "^8.2",
"ext-intl": "*",
"codeigniter4/framework": "4.7.2",
"dompdf/dompdf": "^2.0.3",
"ezyang/htmlpurifier": "^4.17",
@@ -56,7 +50,7 @@
},
"autoload": {
"psr-4": {
"App\\": "app/",
"App\\": "app/",
"CodeIgniter\\": "vendor/codeigniter4/framework/system/"
},
"exclude-from-classmap": [
@@ -73,5 +67,8 @@
},
"scripts": {
"test": "phpunit"
},
"scripts-descriptions": {
"test": "Run unit tests"
}
}

8
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e95f6e5e86d323370ddb0df57c4d3fb3",
"content-hash": "eabbc14aefdea4c933869069d6eadadb",
"packages": [
{
"name": "codeigniter4/framework",
@@ -5382,9 +5382,9 @@
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"ext-intl": "*",
"php": "^8.1"
"php": "^8.2",
"ext-intl": "*"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

View File

@@ -46,6 +46,7 @@ services:
- .:/app
environment:
- CI_ENVIRONMENT=development
- ALLOWED_HOSTNAMES=localhost
- MYSQL_USERNAME=admin
- MYSQL_PASSWORD=pointofsale
- MYSQL_DB_NAME=ospos

View File

@@ -16,6 +16,7 @@ services:
- logs:/app/writable/logs
environment:
- CI_ENVIRONMENT=production
- ALLOWED_HOSTNAMES=localhost
- FORCE_HTTPS=false
- PHP_TIMEZONE=UTC
- MYSQL_USERNAME=admin

View File

@@ -300,6 +300,7 @@ gulp.task('copy-menubar', function() {
// Run all required tasks
gulp.task('default',
gulp.series('clean',
'update-licenses',
'copy-bootswatch',
'copy-bootswatch5',
'copy-bootstrap',

1701
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,33 @@
{
"name": "@opensourcepos/opensourcepos",
"version": "3.4.2",
"description": "pen Source Point of Sale is a web based point of sale system written in the PHP language. It uses MySQL as the data storage back-end and has a simple user interface.",
"main": "index.php",
"license": "MIT",
"authors": [
"jekkos <jekkos - at - opensourcepos.org>",
"FrancescoUK <francesco.lodolo.uk - at - gmail.com>",
"objecttothis <objecttothis - at - gmail.com>",
"SteveIreland <stevei - at - ruledomain.com>"
],
"files": [
"dist/opensourcepos.$version.tgz"
],
"publishConfig": {
"registry": "https://npm.pkg.github.com/"
},
"description": "Open Source Point of Sale is a web based point of sale system written in the PHP language. It uses MySQL as the data storage back-end and has a simple user interface.",
"keywords": [
"point-of-sale",
"POS"
],
"homepage": "https://opensourcepos.org",
"bugs": {
"url": "https://github.com/opensourcepos/opensourcepos/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/opensourcepos/opensourcepos"
"url": "git+https://github.com/opensourcepos/opensourcepos.git"
},
"license": "MIT",
"contributors": [
"jekkos <jekkos - at - opensourcepos.org>",
"objecttothis <objecttothis - at - gmail.com>"
],
"files": [
"dist/opensourcepos.$version.tgz"
],
"type": "module",
"main": "index.php",
"scripts": {
"build": "gulp default",
"gulp": "gulp"
},
"type": "module",
"dependencies": {
"bootstrap": "^3.4.1",
"bootstrap-daterangepicker": "^2.1.27",
@@ -39,9 +38,9 @@
"bootstrap-tagsinput-2021": "^0.8.6",
"bootstrap-toggle": "^2.2.2",
"bootstrap3-dialog": "github:nakupanda/bootstrap3-dialog#master",
"bootstrap5": "npm:bootstrap@^5.3.5",
"bootstrap5": "npm:bootstrap@^5.3.8",
"bootswatch": "^3.4.1",
"bootswatch5": "npm:bootswatch@^5.3.5",
"bootswatch5": "npm:bootswatch@^5.3.8",
"chartist": "^0.11.4",
"chartist-plugin-axistitle": "^0.0.7",
"chartist-plugin-barlabels": "^0.0.5",
@@ -64,23 +63,26 @@
"tableexport.jquery.plugin": "^1.30.0"
},
"devDependencies": {
"gulp": "^5.0.0",
"gulp": "^5.0.1",
"gulp-clean": "^0.4.0",
"gulp-clean-css": "^4.3.0",
"gulp-concat": "^2.6.1",
"gulp-debug": "^5.0.1",
"gulp-gzip": "^1.4.2",
"gulp-header": "^2.0.9",
"gulp-header": "^2.0.12",
"gulp-inject": "^5.0.5",
"gulp-rename": "^2.0.0",
"gulp-rev": "^10.0.0",
"gulp-rename": "^2.1.0",
"gulp-rev": "^12.0.0",
"gulp-run": "^1.7.1",
"gulp-tar": "^4.0.0",
"gulp-tar": "^5.0.0",
"gulp-uglify": "^3.0.2",
"gulp-zip": "^6.1.0",
"license-report": "^6.7.2",
"npm-check-updates": "^17.1.14",
"license-report": "^6.8.2",
"npm-check-updates": "^22.1.1",
"readable-stream": "^4.4.2",
"stream-series": "^0.1.1"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com/"
}
}

View File

@@ -1,12 +1,15 @@
<?php
use CodeIgniter\Boot;
use Config\Paths;
/*
*---------------------------------------------------------------
* CHECK PHP VERSION
*---------------------------------------------------------------
*/
$minPhpVersion = '8.1'; // If you update this, don't forget to update `spark`.
$minPhpVersion = '8.2'; // If you update this, don't forget to update `spark`.
if (version_compare(PHP_VERSION, $minPhpVersion, '<')) {
$message = sprintf(
'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s',
@@ -48,9 +51,9 @@ if (getcwd() . DIRECTORY_SEPARATOR !== FCPATH) {
require FCPATH . '../app/Config/Paths.php';
// ^^^ Change this line if you move your application folder
$paths = new Config\Paths();
$paths = new Paths();
// LOAD THE FRAMEWORK BOOTSTRAP FILE
require $paths->systemDirectory . '/Boot.php';
exit(CodeIgniter\Boot::bootWeb($paths));
exit(Boot::bootWeb($paths));

381
scripts/install-ubuntu.sh Normal file
View File

@@ -0,0 +1,381 @@
#!/bin/bash
set -e
COLOR_RED='\033[0;31m'
COLOR_GREEN='\033[0;32m'
COLOR_YELLOW='\033[1;33m'
COLOR_BLUE='\033[0;34m'
COLOR_RESET='\033[0m'
echo -e "${COLOR_BLUE}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ Open Source Point of Sale - Ubuntu Installer ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ Version 3.4+ ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
if [ "$EUID" -ne 0 ]; then
echo -e "${COLOR_RED}Please run this script as root or with sudo${COLOR_RESET}"
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
DB_HOST="${DB_HOST:-localhost}"
DB_NAME="${DB_NAME:-ospos}"
DB_USER="${DB_USER:-ospos}"
DB_PASS="${DB_PASS:-$(openssl rand -base64 24)}"
OSPOS_DIR="${OSPOS_DIR:-/var/www/ospos}"
OSPOS_VERSION="${OSPOS_VERSION:-}"
PHP_VERSION="${PHP_VERSION:-8.2}"
APACHE_SERVER_NAME="${APACHE_SERVER_NAME:-}"
SSL_EMAIL="${SSL_EMAIL:-}"
SSL_DOMAIN="${SSL_DOMAIN:-}"
MYSQL_ROOT_PASS="${MYSQL_ROOT_PASS:-}"
# Validate database variables contain only safe characters (alphanumeric, underscore, hyphen, dot)
validate_db_vars() {
local var_name="$1"
local var_value="$2"
local pattern='^[a-zA-Z0-9_\-\.]+$'
if [[ ! "$var_value" =~ $pattern ]]; then
echo -e "${COLOR_RED}Error: ${var_name} contains invalid characters. Only alphanumeric, underscore, hyphen, and dot are allowed.${COLOR_RESET}"
exit 1
fi
}
# Validate critical database variables
validate_db_vars "DB_NAME" "$DB_NAME"
validate_db_vars "DB_USER" "$DB_USER"
validate_db_vars "DB_HOST" "$DB_HOST"
# Check if running interactively
INTERACTIVE=false
if [ -t 0 ]; then
INTERACTIVE=true
fi
echo -e "${COLOR_YELLOW}Configuration:${COLOR_RESET}"
echo -e " Database Name: ${DB_NAME}"
echo -e " Database User: ${DB_USER}"
echo -e " Database Host: ${DB_HOST}"
echo -e " Install Directory: ${OSPOS_DIR}"
echo -e " PHP Version: ${PHP_VERSION}"
if [ -n "$OSPOS_VERSION" ]; then
echo -e " OSPOS Version: ${OSPOS_VERSION}"
else
echo -e " OSPOS Version: latest"
fi
if [ -n "$APACHE_SERVER_NAME" ]; then
echo -e " Server Name: ${APACHE_SERVER_NAME}"
fi
echo ""
if [ -d "$OSPOS_DIR" ]; then
echo -e "${COLOR_RED}Installation directory $OSPOS_DIR already exists${COLOR_RESET}"
echo -e "${COLOR_YELLOW}Remove it or set OSPOS_DIR environment variable${COLOR_RESET}"
exit 1
fi
echo -e "${COLOR_GREEN}[1/9] Updating system packages...${COLOR_RESET}"
apt-get update -qq
echo -e "${COLOR_GREEN}[2/9] Installing Apache, PHP, and dependencies...${COLOR_RESET}"
# Add PHP repository for newer PHP versions if not available in default repos
if ! apt-cache policy php${PHP_VERSION} 2>/dev/null | grep -q "Candidate:"; then
echo -e "${COLOR_YELLOW}PHP ${PHP_VERSION} not in default repos, adding ondrej/php PPA...${COLOR_RESET}"
apt-get install -y -qq software-properties-common
add-apt-repository -y ppa:ondrej/php
apt-get update -qq
fi
apt-get install -y -qq \
apache2 \
mariadb-server \
mariadb-client \
php${PHP_VERSION} \
php${PHP_VERSION}-mysql \
php${PHP_VERSION}-gd \
php${PHP_VERSION}-bcmath \
php${PHP_VERSION}-intl \
php${PHP_VERSION}-mbstring \
php${PHP_VERSION}-curl \
php${PHP_VERSION}-xml \
php${PHP_VERSION}-zip \
git \
curl \
unzip \
openssl
echo -e "${COLOR_GREEN}[3/9] Starting MariaDB...${COLOR_RESET}"
systemctl start mariadb
systemctl enable mariadb
if [ -z "$MYSQL_ROOT_PASS" ]; then
echo -e "${COLOR_BLUE}Securing MariaDB installation...${COLOR_RESET}"
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '';"
mysql -e "FLUSH PRIVILEGES;"
else
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${MYSQL_ROOT_PASS}';"
fi
echo -e "${COLOR_GREEN}[4/9] Creating database and user...${COLOR_RESET}"
if [ -n "$MYSQL_ROOT_PASS" ]; then
mysql -u root -p"${MYSQL_ROOT_PASS}" <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'${DB_HOST}' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'${DB_HOST}';
FLUSH PRIVILEGES;
EOF
else
mysql -u root <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'${DB_HOST}' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'${DB_HOST}';
FLUSH PRIVILEGES;
EOF
fi
echo -e "${COLOR_GREEN}[5/9] Downloading OSPOS...${COLOR_RESET}"
mkdir -p "$(dirname "$OSPOS_DIR")"
cd "$(dirname "$OSPOS_DIR")"
if [ -z "$OSPOS_VERSION" ]; then
OSPOS_VERSION=$(curl -sS https://api.github.com/repos/opensourcepos/opensourcepos/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$OSPOS_VERSION" ]; then
echo -e "${COLOR_RED}Failed to get latest release version${COLOR_RESET}"
exit 1
fi
fi
echo -e "${COLOR_BLUE}Downloading OSPOS version ${OSPOS_VERSION}...${COLOR_RESET}"
ASSET_URL=$(curl -sS "https://api.github.com/repos/opensourcepos/opensourcepos/releases/tags/${OSPOS_VERSION}" | grep '"browser_download_url"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$ASSET_URL" ]; then
echo -e "${COLOR_RED}Failed to find release asset for ${OSPOS_VERSION}${COLOR_RESET}"
exit 1
fi
curl -sSL "$ASSET_URL" -o ospos.zip
if [ ! -f ospos.zip ] || [ ! -s ospos.zip ]; then
echo -e "${COLOR_RED}Failed to download OSPOS release ${OSPOS_VERSION}${COLOR_RESET}"
rm -f ospos.zip
exit 1
fi
unzip -q ospos.zip -d ospos-temp
mkdir -p "${OSPOS_DIR}"
cp -r ospos-temp/. "${OSPOS_DIR}/"
rm -rf ospos-temp ospos.zip
echo -e "${COLOR_GREEN}Downloaded OSPOS ${OSPOS_VERSION}${COLOR_RESET}"
echo -e "${COLOR_GREEN}[6/9] Setting up OSPOS...${COLOR_RESET}"
cd "${OSPOS_DIR}"
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 2>/dev/null
if [ -f "composer.json" ]; then
echo -e "${COLOR_BLUE}Installing dependencies...${COLOR_RESET}"
composer install --no-dev --optimize-autoloader --no-interaction --quiet 2>/dev/null
fi
echo -e "${COLOR_GREEN}[7/9] Configuring OSPOS...${COLOR_RESET}"
if [ -f ".env.example" ]; then
cp .env.example .env
fi
if [ -f ".env" ]; then
# Escape special characters in password for sed
ESCAPED_DB_PASS=$(printf '%s\n' "$DB_PASS" | sed 's/[&/\]/\\&/g')
sed -i "s|database\.default\.hostname = 'localhost'|database.default.hostname = '${DB_HOST}'|" .env
sed -i "s|database\.default\.database = 'ospos'|database.default.database = '${DB_NAME}'|" .env
sed -i "s|database\.default\.username = 'admin'|database.default.username = '${DB_USER}'|" .env
sed -i "s|database\.default\.password = 'pointofsale'|database.default.password = '${ESCAPED_DB_PASS}'|" .env
sed -i "s|CI_ENVIRONMENT = development|CI_ENVIRONMENT = production|" .env
if grep -q "encryption\.key = ''" .env; then
ENCRYPTION_KEY=$(openssl rand -base64 32)
ESCAPED_KEY=$(printf '%s\n' "$ENCRYPTION_KEY" | sed 's/[&/\]/\\&/g')
sed -i "s|encryption\.key = ''|encryption.key = '${ESCAPED_KEY}'|" .env
fi
fi
echo -e "${COLOR_GREEN}[8/9] Importing database schema...${COLOR_RESET}"
if [ -n "$MYSQL_ROOT_PASS" ]; then
mysql -u root -p"${MYSQL_ROOT_PASS}" ${DB_NAME} < app/Database/database.sql
else
mysql -u root ${DB_NAME} < app/Database/database.sql
fi
# Interactive SSL configuration
if $INTERACTIVE && [ -z "$SSL_EMAIL" ] && [ -z "$APACHE_SERVER_NAME" ]; then
echo ""
echo -e "${COLOR_BLUE}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ SSL/TLS Configuration ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
echo -e "${COLOR_YELLOW}SSL provides secure HTTPS access to your OSPOS installation.${COLOR_RESET}"
echo -e "${COLOR_YELLOW}For production, we recommend Let's Encrypt (free SSL certificate).${COLOR_RESET}"
echo ""
read -p "Configure SSL? (y/n) [n]: " CONFIGURE_SSL
CONFIGURE_SSL=${CONFIGURE_SSL:-n}
if [[ "$CONFIGURE_SSL" =~ ^[Yy]$ ]]; then
read -p "Enter your domain name (e.g., pos.example.com): " SSL_DOMAIN
SSL_DOMAIN=${SSL_DOMAIN:-localhost}
APACHE_SERVER_NAME=$SSL_DOMAIN
read -p "Enter your email for Let's Encrypt notifications: " SSL_EMAIL
if [ -z "$SSL_EMAIL" ]; then
echo -e "${COLOR_YELLOW}No email provided. Using self-signed certificate (not recommended for production).${COLOR_RESET}"
SSL_TYPE="self-signed"
else
SSL_TYPE="letsencrypt"
fi
else
APACHE_SERVER_NAME="localhost"
SSL_TYPE="none"
fi
fi
# Set default server name if not provided
if [ -z "$APACHE_SERVER_NAME" ]; then
APACHE_SERVER_NAME="localhost"
fi
# If SSL_EMAIL is set without SSL_DOMAIN, use APACHE_SERVER_NAME
if [ -n "$SSL_EMAIL" ] && [ -z "$SSL_DOMAIN" ] && [ "$APACHE_SERVER_NAME" != "localhost" ]; then
SSL_DOMAIN="$APACHE_SERVER_NAME"
fi
echo -e "${COLOR_GREEN}[9/9] Configuring Apache...${COLOR_RESET}"
cat > /etc/apache2/sites-available/ospos.conf <<EOF
<VirtualHost *:80>
ServerName ${APACHE_SERVER_NAME}
DocumentRoot ${OSPOS_DIR}/public
<Directory ${OSPOS_DIR}/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog \${APACHE_LOG_DIR}/ospos_error.log
CustomLog \${APACHE_LOG_DIR}/ospos_access.log combined
</VirtualHost>
EOF
a2enmod rewrite
a2dissite 000-default.conf
a2ensite ospos.conf
chown -R www-data:www-data "${OSPOS_DIR}"
chmod -R 750 "${OSPOS_DIR}/writable"
systemctl restart apache2
systemctl enable apache2
# Configure SSL if requested
if [ -n "$SSL_EMAIL" ] && [ -n "$SSL_DOMAIN" ]; then
# Let's Encrypt SSL
echo -e "${COLOR_BLUE}Installing Certbot for Let's Encrypt...${COLOR_RESET}"
apt-get install -y -qq certbot python3-certbot-apache
echo -e "${COLOR_BLUE}Obtaining SSL certificate for ${SSL_DOMAIN}...${COLOR_RESET}"
certbot --apache -d ${SSL_DOMAIN} --non-interactive --agree-tos --email ${SSL_EMAIL} --redirect
echo -e "${COLOR_BLUE}Setting up auto-renewal...${COLOR_RESET}"
systemctl enable certbot.timer
systemctl start certbot.timer
PROTOCOL="https"
FINAL_URL="https://${SSL_DOMAIN}/"
elif [ -n "$SSL_DOMAIN" ]; then
# Self-signed SSL
echo -e "${COLOR_BLUE}Generating self-signed SSL certificate...${COLOR_RESET}"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/ospos-selfsigned.key \
-out /etc/ssl/certs/ospos-selfsigned.crt \
-subj "/C=US/ST=State/L=City/O=Organization/CN=${SSL_DOMAIN}" 2>/dev/null
cat > /etc/apache2/sites-available/ospos-ssl.conf <<EOF
<VirtualHost *:443>
ServerName ${SSL_DOMAIN}
DocumentRoot ${OSPOS_DIR}/public
SSLEngine on
SSLCertificateFile /etc/ssl/certs/ospos-selfsigned.crt
SSLCertificateKeyFile /etc/ssl/private/ospos-selfsigned.key
<Directory ${OSPOS_DIR}/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog \${APACHE_LOG_DIR}/ospos_ssl_error.log
CustomLog \${APACHE_LOG_DIR}/ospos_ssl_access.log combined
</VirtualHost>
EOF
a2enmod ssl
a2ensite ospos-ssl.conf
cat > /etc/apache2/sites-available/ospos.conf <<EOF
<VirtualHost *:80>
ServerName ${SSL_DOMAIN}
Redirect permanent / https://${SSL_DOMAIN}/
</VirtualHost>
EOF
a2dissite ospos.conf
a2ensite ospos.conf
PROTOCOL="https"
FINAL_URL="https://${SSL_DOMAIN}/"
echo -e "${COLOR_YELLOW}Note: Your browser will show a security warning for self-signed${COLOR_RESET}"
echo -e "${COLOR_YELLOW} certificates. For production, re-run with an email for Let's Encrypt.${COLOR_RESET}"
else
PROTOCOL="http"
FINAL_URL="http://${APACHE_SERVER_NAME}/"
fi
systemctl restart apache2
# Configure allowed hostnames
if [ -f "${OSPOS_DIR}/.env" ]; then
sed -i "s|app\.allowedHostnames = ''|app.allowedHostnames = '${APACHE_SERVER_NAME}'|" ${OSPOS_DIR}/.env
fi
echo ""
echo -e "${COLOR_GREEN}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_GREEN}║ Installation Complete! ║${COLOR_RESET}"
echo -e "${COLOR_GREEN}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
echo -e "${COLOR_YELLOW}Database Credentials:${COLOR_RESET}"
echo -e " Database: ${DB_NAME}"
echo -e " Username: ${DB_USER}"
echo -e " Password: ${DB_PASS}"
echo ""
echo -e "${COLOR_YELLOW}Login Credentials:${COLOR_RESET}"
echo -e " URL: ${FINAL_URL}"
if [ -n "$SSL_EMAIL" ]; then
echo -e " SSL: Let's Encrypt (auto-renewal enabled)"
elif [ -n "$SSL_DOMAIN" ]; then
echo -e " SSL: Self-signed certificate"
else
echo -e " SSL: Not configured (HTTP only)"
fi
echo -e " Username: admin"
echo -e " Password: pointofsale"
echo ""
echo -e "${COLOR_RED}IMPORTANT: Change the default password after first login!${COLOR_RESET}"
echo ""
echo -e "${COLOR_BLUE}Configuration file: ${OSPOS_DIR}/.env${COLOR_RESET}"
echo ""

9
spark
View File

@@ -10,6 +10,9 @@
* the LICENSE file that was distributed with this source code.
*/
use CodeIgniter\Boot;
use Config\Paths;
/*
* --------------------------------------------------------------------
* CODEIGNITER COMMAND-LINE TOOLS
@@ -35,7 +38,7 @@ if (str_starts_with(PHP_SAPI, 'cgi')) {
*---------------------------------------------------------------
*/
$minPhpVersion = '8.1'; // If you update this, don't forget to update `public/index.php`.
$minPhpVersion = '8.2'; // If you update this, don't forget to update `public/index.php`.
if (version_compare(PHP_VERSION, $minPhpVersion, '<')) {
$message = sprintf(
'Your PHP version must be %s or higher to run CodeIgniter. Current version: %s',
@@ -76,9 +79,9 @@ chdir(FCPATH);
require FCPATH . '../app/Config/Paths.php';
// ^^^ Change this line if you move your application folder
$paths = new Config\Paths();
$paths = new Paths();
// LOAD THE FRAMEWORK BOOTSTRAP FILE
require $paths->systemDirectory . '/Boot.php';
exit(CodeIgniter\Boot::bootSpark($paths));
exit(Boot::bootSpark($paths));

View File

@@ -18,6 +18,7 @@ class AppTest extends CIUnitTestCase
// Clean up environment
putenv('CI_ENVIRONMENT');
putenv('app.allowedHostnames');
putenv('ALLOWED_HOSTNAMES');
unset($_SERVER['HTTP_HOST']);
}
@@ -281,4 +282,106 @@ class AppTest extends CIUnitTestCase
putenv('app.allowedHostnames');
putenv('CI_ENVIRONMENT');
}
public function testAllowedHostnamesEnvVarParsedAsCommaSeparated(): void
{
// Set ALLOWED_HOSTNAMES environment variable
putenv('ALLOWED_HOSTNAMES=example.com,www.example.com,demo.example.com');
$_SERVER['HTTP_HOST'] = 'www.example.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
// Constructor should parse comma-separated values
$this->assertEquals(['example.com', 'www.example.com', 'demo.example.com'], $app->allowedHostnames);
$this->assertStringContainsString('www.example.com', $app->baseURL);
// Clean up
putenv('ALLOWED_HOSTNAMES');
}
public function testAllowedHostnamesEnvVarTakesPrecedenceOverDotEnv(): void
{
// Set both environment variables
putenv('ALLOWED_HOSTNAMES=allowed1.com,allowed2.com');
putenv('app.allowedHostnames=dotenv1.com,dotenv2.com');
$_SERVER['HTTP_HOST'] = 'allowed1.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
// ALLOWED_HOSTNAMES should take precedence
$this->assertEquals(['allowed1.com', 'allowed2.com'], $app->allowedHostnames);
$this->assertStringContainsString('allowed1.com', $app->baseURL);
// Clean up
putenv('ALLOWED_HOSTNAMES');
putenv('app.allowedHostnames');
}
public function testAllowedHostnamesEnvVarFallsBackToDotEnv(): void
{
// Only set app.allowedHostnames, not ALLOWED_HOSTNAMES
putenv('app.allowedHostnames=dotenv1.com,dotenv2.com');
$_SERVER['HTTP_HOST'] = 'dotenv1.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
// Should fall back to app.allowedHostnames
$this->assertEquals(['dotenv1.com', 'dotenv2.com'], $app->allowedHostnames);
$this->assertStringContainsString('dotenv1.com', $app->baseURL);
// Clean up
putenv('app.allowedHostnames');
}
public function testAllowedHostnamesEnvVarTrimmedWhitespace(): void
{
// Set environment variable with whitespace
putenv('ALLOWED_HOSTNAMES= example.com , www.example.com , demo.example.com ');
$_SERVER['HTTP_HOST'] = 'example.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
// Values should be trimmed
$this->assertEquals(['example.com', 'www.example.com', 'demo.example.com'], $app->allowedHostnames);
// Clean up
putenv('ALLOWED_HOSTNAMES');
}
public function testAllowedHostnamesEnvVarFiltersEmptyEntries(): void
{
// Trailing comma should not produce empty entry
putenv('ALLOWED_HOSTNAMES=example.com,');
$_SERVER['HTTP_HOST'] = 'example.com';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['HTTPS'] = null;
$app = new App();
$this->assertEquals(['example.com'], $app->allowedHostnames);
// Clean up
putenv('ALLOWED_HOSTNAMES');
// Whitespace-only entry should be filtered
putenv('ALLOWED_HOSTNAMES=example.com, ,www.example.com');
$_SERVER['HTTP_HOST'] = 'example.com';
$app = new App();
$this->assertEquals(['example.com', 'www.example.com'], $app->allowedHostnames);
// Clean up
putenv('ALLOWED_HOSTNAMES');
}
}