Compare commits

..

18 Commits

Author SHA1 Message Date
Ollama
e3c2175b04 feat: Add show_in_search language strings to all languages
- Add 'show_in_search' and 'show_in_search_visibility' to all language files
- Translated: de-DE, es-ES, fr, it
- Placeholder English for remaining languages (awaiting translation)

This ensures the SHOW_IN_SEARCH attribute flag works in all locales.
2026-05-16 21:02:34 +02:00
Ollama
2d93a96ac9 fix: Make item_sort_columns return format compatible with sanitizeSortColumn
- sanitizeSortColumn expects nested array format [['col' => 'label'], ...]
- Reuse item_headers() as base and add attribute columns
- This maintains DRY principle by not redefining columns twice
2026-05-16 21:02:34 +02:00
Ollama
c681dc51ed fix: Use typed column for attribute sorting (DECIMAL/DATE/TEXT)
- When sorting by attribute column, determine the attribute type
- Use attribute_decimal for DECIMAL type
- Use attribute_date for DATE type
- Use attribute_value for TEXT/DROPDOWN/CHECKBOX types
- This ensures numeric and date attributes sort correctly instead of lexicographically

Fixes CodeRabbit feedback on PR #4442.
2026-05-16 21:02:34 +02:00
Ollama
65568cf224 fix: Address remaining PR review comments
- Update regex pattern to support multi-word attribute names (e.g., 'aspect ratio')
- Rename $includeDeleted to $matchDeleted for clarity (matches deleted flag value)
- Add check to skip attributes not in caller-provided definitionIds filter
- Use Unicode pattern modifier 'u' for international character support in attribute names

This addresses CodeRabbit's feedback on PR #4442.
2026-05-16 21:02:34 +02:00
Ollama
5cb4371344 fix: Address PR review comments
- Rename methods to camelCase (PSR-12): parseAttributeSearch, searchByAttributes, searchByAttributeValue, getAttributeSortDefinitionId
- Rename variables to camelCase (PSR-12)
- Add DATE attribute search support in searchByAttributeValue
- Fix get_definitions_by_flags to request typed payload (second param true)
- Handle both array and string return shapes for definition info
- Fix term-only search to merge with attribute matches instead of replacing
- Use strict emptiness checks (=== '' instead of empty())
- Sanitize definition_ids before SQL interpolation (array_map intval)
- Include DATE in attribute search conditions
2026-05-16 21:02:34 +02:00
Ollama
281f402b8e style: Apply PSR-12 formatting
- Fix PHPDoc comment format in item_sort_columns() helper
- Remove duplicate blank line after properties in Item model
- Remove unused variable assignment in Items controller
2026-05-16 21:02:34 +02:00
Ollama
c74fa7aeb5 Refactor: Use existing sanitizeSortColumn method with item_sort_columns helper
- Add item_sort_columns() helper function in tabular_helper.php
- Helper returns all sortable columns including dynamic attribute IDs
- Remove duplicate sanitizeSortColumnAttribute method from Items controller
- Remove unused ALLOWED_SORT_COLUMNS constant from Item model
- Reuses existing sanitizeSortColumn method from Secure_Controller
2026-05-16 21:02:34 +02:00
Ollama
be01555a34 Refactor: Add ALLOWED_SORT_COLUMNS constant and reuse in sanitization
- Add Item::ALLOWED_SORT_COLUMNS constant for allowed sort columns
- Use constant in sanitizeSortColumnAttribute() instead of inline array
- Enables reuse across the codebase for sort column validation
2026-05-16 21:02:34 +02:00
Ollama
abc381d35c Implement Phase 3: Multi-attribute search AND logic and Phase 4: Sort by attribute columns
Phase 3 - Multi-attribute Search:
- Add parse_attribute_search() method to parse search syntax like
  'color: blue size: large' or 'color:blue AND size:large'
- Update search_by_attributes() to support AND/OR logic for multiple
  attribute queries
- Add search_by_attribute_value() private method for single value search

Phase 4 - Sort by Attribute Columns:
- Add get_attribute_sort_definition_id() method to detect attribute
  column sorting
- Update Item::search() to join attribute tables when sorting by
  attribute columns
- Update tabular_helper to make attribute columns sortable
- Add sanitizeSortColumnAttribute() method in Items controller to
  validate attribute definition IDs as sort columns

Fixes #2722 - sorting by attribute columns now works
2026-05-16 21:02:34 +02:00
Ollama
cacd320206 Add SHOW_IN_SEARCH flag to separate attribute search from visibility
Phase 2 implementation:
- Add SHOW_IN_SEARCH constant (value 8) to Attribute model
- Update Items controller to include searchable attributes when
  search_custom filter is enabled
- Add language strings for the new visibility flag
- Update Attributes controller to include new flag in form

This allows attributes to be searchable even if they are not
displayed in the items table, addressing issue #2919.

Fixes #2919
2026-05-16 21:02:34 +02:00
Ollama
a7a52f800c Refactor attribute search to fix pagination and multi-attribute search
- Add new search_by_attributes() method that returns matching item_ids
- Refactor search() method to use subquery approach instead of HAVING LIKE
- Fix pagination count issue (#2819) - get_found_rows() now returns correct count
- Enable combined search (attributes OR regular fields)
- GROUP_CONCAT still used for display but removed from search/count logic
- Fixes #2407 - multi-attribute search now works correctly

Related: #2819, #2407, #2722, #2919
2026-05-16 21:02:34 +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
75 changed files with 691 additions and 508 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'
#

View File

@@ -123,6 +123,7 @@ jobs:
.
!.git
!node_modules
include-hidden-files: true
retention-days: 1
docker:

View File

@@ -1,204 +0,0 @@
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:
deploy-staging:
name: Deploy to staging
runs-on: ubuntu-latest
if: >
github.event.review.state == 'approved' &&
github.event.pull_request.head.repo.full_name == github.repository
environment:
name: staging
url: ${{ vars.DEPLOY_URL || 'https://dev.opensourcepos.org' }}
deployment: false
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Get image tag
id: image
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
run: |
IMAGE_TAG="pr-${PR_NUMBER}-${PR_SHA:0:7}"
echo "tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
- name: Create GitHub Deployment
id: deployment
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REF_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
DEPLOYMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/deployments" \
-X POST \
-f ref="${REF_SHA}" \
-f environment="staging" \
-f description="Deploy PR #${PR_NUMBER} to staging" \
-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 }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/deployments/${{ steps.deployment.outputs.deployment_id }}/statuses" \
-X POST \
-f state="in_progress" \
-f description="Deploying PR #${PR_NUMBER}..." \
-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: ${{ steps.image.outputs.tag }}
REF_SHA: ${{ github.event.pull_request.head.sha }}
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ -z "$DEPLOY_WEBHOOK_URL" ]; then
echo "::error::DEPLOY_WEBHOOK_URL secret is not configured"
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)
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}
}')
echo "Sending webhook..."
echo "Image: ${IMAGE_TAG}"
echo "PR: #${PR_NUMBER}"
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")
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: ${{ steps.image.outputs.tag }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
STATE="${{ steps.webhook.outputs.status }}"
if [ "$STATE" = "success" ]; then
DESCRIPTION=$(jq -nr --arg tag "$IMAGE_TAG" --arg pr "$PR_NUMBER" \
'"Deployed PR #\($pr) (\($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="Staging deployment failed"
exit 1
fi
- name: Comment deployment status
if: always()
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ steps.image.outputs.tag }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REF_SHA: ${{ github.event.pull_request.head.sha }}
STATUS: ${{ steps.webhook.outputs.status }}
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"

View File

@@ -1,214 +0,0 @@
name: Deploy
on:
workflow_dispatch:
inputs:
image_tag:
description: 'Docker image tag to deploy (e.g., v3.4.0, latest)'
required: true
default: 'latest'
environment:
description: 'Target environment'
required: true
type: choice
options:
- production
- staging
default: 'production'
workflow_call:
inputs:
image_tag:
description: 'Docker image tag to deploy'
type: string
default: 'latest'
environment:
description: 'Target environment'
type: string
default: 'staging'
sha:
description: 'Git commit SHA to deploy'
required: true
type: string
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
permissions:
contents: read
deployments: write
jobs:
validate-inputs:
name: Validate deployment inputs
runs-on: ubuntu-latest
steps:
- name: Validate environment
env:
TARGET_ENV: ${{ inputs.environment }}
run: |
set -euo pipefail
case "$TARGET_ENV" in
production|staging) ;;
*)
echo "::error::Invalid environment '$TARGET_ENV'. Expected 'production' or 'staging'."
exit 1
;;
esac
deploy:
name: Deploy to ${{ inputs.environment }}
needs: validate-inputs
runs-on: ubuntu-latest
environment:
name: ${{ inputs.environment }}
url: ${{ vars.DEPLOY_URL || (inputs.environment == 'production' && 'https://demo.opensourcepos.org' || 'https://dev.opensourcepos.org') }}
deployment: false
steps:
- name: Create GitHub Deployment
id: deployment
env:
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ inputs.image_tag }}
TARGET_ENV: ${{ inputs.environment }}
REF_SHA: ${{ inputs.sha || github.sha }}
run: |
set -euo pipefail
DEPLOYMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/deployments" \
-X POST \
-f ref="${REF_SHA}" \
-f environment="${TARGET_ENV}" \
-f description="Deploy image ${IMAGE_TAG}" \
-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 }}
TARGET_ENV: ${{ inputs.environment }}
REF_SHA: ${{ inputs.sha || github.sha }}
DEPLOYMENT_ID: ${{ steps.deployment.outputs.deployment_id }}
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)
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 environment "$TARGET_ENV" \
--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: $environment, repository: $repository, sha: $sha, run_id: $run_id, actor: $actor}
}')
echo "Sending webhook..."
echo "Image: ${IMAGE_TAG}"
echo "Environment: ${TARGET_ENV}"
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 }}
TARGET_ENV: ${{ inputs.environment }}
run: |
set -euo pipefail
STATE="${{ steps.webhook.outputs.status }}"
if [ "$STATE" = "success" ]; then
DESCRIPTION=$(jq -nr --arg tag "$IMAGE_TAG" --arg env "$TARGET_ENV" \
'"Deployed image \($tag) to \($env)"')
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

View File

@@ -7,17 +7,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& docker-php-ext-install mysqli bcmath intl gd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& a2enmod rewrite \
&& sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
&& a2enmod rewrite
RUN echo "date.timezone = \"\${PHP_TIMEZONE}\"" > /usr/local/etc/php/conf.d/timezone.ini
WORKDIR /app
COPY --chown=www-data:www-data . /app
RUN chmod 770 /app/writable/uploads /app/writable/logs /app/writable/cache \
&& mkdir -p /app/public/uploads/item_pics \
&& chown www-data:www-data /app/public/uploads/item_pics \
&& chmod 640 /app/.env \
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

@@ -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,9 +331,8 @@ class App extends BaseConfig
$errorMessage =
'Security: allowedHostnames is not configured. ' .
'Host header injection protection is disabled. ' .
'Either set app.allowedHostnames in your .env file ' .
'(e.g., app.allowedHostnames = "example.com,www.example.com") ' .
'or configure $allowedHostnames in app/Config/App.php. ' .
'Set app.allowedHostnames in your .env file or ALLOWED_HOSTNAMES environment variable. ' .
'Example: app.allowedHostnames = "example.com,www.example.com" ' .
'Received Host: ' . $httpHost;
// Production: Fail explicitly to prevent silent security vulnerabilities

View File

@@ -48,7 +48,8 @@ class OSPOS extends BaseConfig
$this->settings = [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home'
'company' => 'Home',
'barcode_type' => 'Code39'
];
}
}

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;
$show_all = Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_RECEIVINGS | Attribute::SHOW_IN_SALES | Attribute::SHOW_IN_SEARCH;
$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

@@ -924,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'),

View File

@@ -105,13 +105,14 @@ class Items extends Secure_Controller
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$sort = $this->sanitizeSortColumn(item_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id');
$definition_names = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS);
$sort = $this->sanitizeSortColumn(item_sort_columns(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'items.item_id');
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$this->item_lib->set_item_location($this->request->getGet('stock_location'));
$definition_names = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS);
$filters = [
'start_date' => $this->request->getGet('start_date'),
'end_date' => $this->request->getGet('end_date'),
@@ -129,6 +130,13 @@ class Items extends Secure_Controller
// Check if any filter is set in the multiselect dropdown
$request_filters = array_fill_keys($this->request->getGet('filters', FILTER_SANITIZE_FULL_SPECIAL_CHARS) ?? [], true);
$filters = array_merge($filters, $request_filters);
// When search_custom is enabled, include attributes that are searchable but may not be visible in table
if (!empty($filters['search_custom'])) {
$searchable_definitions = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH);
$filters['definition_ids'] = array_keys($searchable_definitions);
}
$items = $this->item->search($search, $filters, $limit, $offset, $sort, $order);
$total_rows = $this->item->get_found_rows($search, $filters);
$data_rows = [];
@@ -154,8 +162,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);

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;
}

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

@@ -402,6 +402,25 @@ function item_headers(): array
];
}
/**
* Get all sortable column keys for items table, including dynamic attribute columns.
*
* @return array Array of column headers in format expected by sanitizeSortColumn
*/
function item_sort_columns(): array
{
$attribute = model(Attribute::class);
$definitionIds = array_keys($attribute->get_definitions_by_flags($attribute::SHOW_IN_ITEMS));
$headers = item_headers();
foreach ($definitionIds as $definitionId) {
$headers[] = [(string) $definitionId => ''];
}
return $headers;
}
/**
* Get the header for the items tabular view
*/
@@ -422,7 +441,7 @@ function get_items_manage_table_headers(): string
$headers[] = ['item_pic' => lang('Items.image'), 'sortable' => false];
foreach ($definitionsWithTypes as $definition_id => $definitionInfo) {
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => false];
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => true];
}
$headers[] = ['inventory' => '', 'escape' => false];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "استلام البضائع",
"show_in_sales" => "اظهار خلال البيع",
"show_in_sales_visibility" => "البيع",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "تحديث الميزات",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "استلام البضائع",
"show_in_sales" => "اظهار خلال البيع",
"show_in_sales_visibility" => "البيع",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "تحديث الميزات",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Alınanlar",
"show_in_sales" => "Satışda göstərin",
"show_in_sales_visibility" => "Satışlar",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Atributları yenilə",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Ulazi",
"show_in_sales" => "Prikaži u prodaji",
"show_in_sales_visibility" => "Prodaja",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Ažuriraj atribut",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "بەدەستگەیشتووکان",
"show_in_sales" => "لە فرۆشتندا نیشانی بدە",
"show_in_sales_visibility" => "فرۆشتن",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "تایبەتمەندی نوێ بکەرەوە",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Modtagelser",
"show_in_sales" => "Vis i salg",
"show_in_sales_visibility" => "Salg",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Opdater egenskab",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Eingänge",
"show_in_sales" => "In Verkäufen anzeigen",
"show_in_sales_visibility" => "Verkauf",
"show_in_search" => "In Suche anzeigen",
"show_in_search_visibility" => "Suche",
"update" => "Attribut aktualisieren",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

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

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

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

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Recibos",
"show_in_sales" => "Mostrar en ventas",
"show_in_sales_visibility" => "Ventas",
"show_in_search" => "Mostrar en búsqueda",
"show_in_search_visibility" => "Búsqueda",
"update" => "Actualizar Atributo",
];

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

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Recepciones",
"show_in_sales" => "Mostrar en Ventas",
"show_in_sales_visibility" => "Ventas",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Actualizar atributo",
];

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

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "دریافت",
"show_in_sales" => "نمایش در فروش",
"show_in_sales_visibility" => "حراجی",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "به روز کردن ویژگی",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Réceptions",
"show_in_sales" => "Afficher dans les ventes",
"show_in_sales_visibility" => "Ventes",
"show_in_search" => "Afficher dans la recherche",
"show_in_search_visibility" => "Recherche",
"update" => "Mettre à jour l'attribut",
];

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

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "קבלת סחורה",
"show_in_sales" => "הצג במכירות",
"show_in_sales_visibility" => "מכירות",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "עדכן מאפיין",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Áruátvételek",
"show_in_sales" => "Megjelenítés az értékesítésekben",
"show_in_sales_visibility" => "Értékesítések",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Tulajdonság frissítése",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Penerimaan",
"show_in_sales" => "Tampilkan dalam penjualan",
"show_in_sales_visibility" => "Penjualan",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Perbarui Atribut",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Ricezione",
"show_in_sales" => "Visualizza in vendite",
"show_in_sales_visibility" => "Vendite",
"show_in_search" => "Visualizza nella ricerca",
"show_in_search_visibility" => "Ricerca",
"update" => "Aggiorna attributo",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "",
"show_in_sales" => "",
"show_in_sales_visibility" => "",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Orders",
"show_in_sales" => "Toon in verkoop",
"show_in_sales_visibility" => "Verkoop",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Wijzig Attribuut",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Leveringen",
"show_in_sales" => "Weergeven in verkopen",
"show_in_sales_visibility" => "Verkopen",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Kenmerk bijwerken",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Dostawy",
"show_in_sales" => "Pokaż w sprzedażach",
"show_in_sales_visibility" => "Sprzedaże",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Zaktualizuj atrybut",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Recebimentos",
"show_in_sales" => "Mostrar em vendas",
"show_in_sales_visibility" => "Vendas",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Atualizar atributo",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Receptii",
"show_in_sales" => "Arata in vanzari",
"show_in_sales_visibility" => "Vanzari",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Actualizare Atribut",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Закупки",
"show_in_sales" => "Показать в продажах",
"show_in_sales_visibility" => "Продажи",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Обновить атрибут",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Inleveranser",
"show_in_sales" => "Visa i försäljning",
"show_in_sales_visibility" => "Försäljning",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Uppdatera attribut",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Manunuzi",
"show_in_sales" => "Onyesha kwenye Mauzo",
"show_in_sales_visibility" => "Mauzo",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Sasisha Sifa",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Manunuzi",
"show_in_sales" => "Onyesha kwenye Mauzo",
"show_in_sales_visibility" => "Mauzo",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Sasisha Sifa",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "สินค้าขาเข้า",
"show_in_sales" => "แสดงใน การขาย",
"show_in_sales_visibility" => "การขาย",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "ปรับปรุงแอตทริบิวต์",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Alacaklar",
"show_in_sales" => "Satışlarda göster",
"show_in_sales_visibility" => "Satışlar",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Nitelik Güncelle",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Надходження",
"show_in_sales" => "Показати в продажах",
"show_in_sales_visibility" => "Продажі",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Оновити атрибут",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Receivings",
"show_in_sales" => "Show in sales",
"show_in_sales_visibility" => "Sales",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Update Attribute",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "Nhập hàng",
"show_in_sales" => "Hiển thị trong bán hàng",
"show_in_sales_visibility" => "Bán hàng",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "Cập nhật thuộc tính",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "收据",
"show_in_sales" => "在销售中显示",
"show_in_sales_visibility" => "销售",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "更新属性",
];

View File

@@ -30,5 +30,7 @@ return [
"show_in_receivings_visibility" => "收貨",
"show_in_sales" => "在銷售中顯示",
"show_in_sales_visibility" => "銷售",
"show_in_search" => "Show in search",
"show_in_search_visibility" => "Search",
"update" => "更新屬性",
];

View File

@@ -108,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 = [];
@@ -161,6 +166,11 @@ class Sale_lib
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

@@ -38,9 +38,10 @@ class Attribute extends Model
'attribute_decimal'
];
public const SHOW_IN_ITEMS = 1; // TODO: These need to be moved to constants.php
public const SHOW_IN_ITEMS = 1;
public const SHOW_IN_SALES = 2;
public const SHOW_IN_RECEIVINGS = 4;
public const SHOW_IN_SEARCH = 8;
public function deleteDropdownAttributeValue(string $attribute_value, int $definition_id): bool
{
$attribute_id = $this->getAttributeIdByValue($attribute_value);

View File

@@ -31,6 +31,7 @@ class Item extends Model
'allow_alt_description',
'is_serialized'
];
protected $table = 'items';
protected $primaryKey = 'item_id';
protected $useAutoIncrement = true;
@@ -58,7 +59,6 @@ class Item extends Model
'hsn_code'
];
/**
* Determines if a given item_id is an item
*/
@@ -132,32 +132,186 @@ class Item extends Model
return $this->search($search, $filters, 0, 0, 'items.name', 'asc', true);
}
/**
* Parse search string for attribute-specific queries
* Supports syntax like "color: blue size: large" or "color:blue AND size:large"
*
* @param string $search The raw search string
* @return array{terms: array, attributes: array} Parsed terms and attribute queries
*/
public function parseAttributeSearch(string $search): array
{
$result = [
'terms' => [],
'attributes' => []
];
if ($search === '') {
return $result;
}
$pattern = '/([[:alpha:]][[:alnum:] _-]*?)\s*:\s*([^\s,]+)(?:\s+(?:AND|OR)\s+)?/iu';
$remaining = preg_replace($pattern, '', $search);
if (preg_match_all($pattern, $search, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$attrName = strtolower(trim($match[1]));
$attrValue = trim($match[2]);
$result['attributes'][$attrName][] = $attrValue;
}
}
$remaining = trim(preg_replace('/\s+/', ' ', $remaining));
if ($remaining !== '') {
$result['terms'][] = $remaining;
}
return $result;
}
/**
* Search for items by attribute values
* Returns an array of item_ids matching the attribute search criteria
*
* @param string $search Search term
* @param array $definitionIds Attribute definition IDs to search within
* @param bool $matchDeleted Whether to match items where deleted flag equals this value
* @param string $logic 'AND' or 'OR' for multiple attribute matching
* @return array Array of matching item_ids
*/
public function searchByAttributes(string $search, array $definitionIds, bool $matchDeleted = false, string $logic = 'OR'): array
{
if ($definitionIds === [] || $search === '') {
return [];
}
$parsed = $this->parseAttributeSearch($search);
$matchingItemIds = [];
if (!empty($parsed['attributes'])) {
$attribute = model(Attribute::class);
$allDefinitions = $attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH, true);
$definitionNameToId = [];
foreach ($allDefinitions as $id => $defInfo) {
$name = is_array($defInfo) ? $defInfo['name'] : $defInfo;
$definitionNameToId[strtolower($name)] = (int) $id;
}
foreach ($parsed['attributes'] as $attrName => $values) {
if (!isset($definitionNameToId[$attrName])) {
continue;
}
$definitionId = $definitionNameToId[$attrName];
// Skip if this attribute is not in the caller-provided definitionIds filter
if (!in_array($definitionId, $definitionIds, true)) {
continue;
}
foreach ($values as $value) {
$builder = $this->db->table('attribute_links');
$builder->select('DISTINCT attribute_links.item_id');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
$builder->join('items', 'items.item_id = attribute_links.item_id');
$builder->groupStart();
$builder->like('attribute_values.attribute_value', $value);
$builder->orWhere('attribute_values.attribute_decimal', $value);
$builder->orWhere('attribute_values.attribute_date', $value);
$builder->groupEnd();
$builder->where('attribute_links.definition_id', $definitionId);
$builder->where('attribute_links.sale_id', null);
$builder->where('attribute_links.receiving_id', null);
$builder->where('items.deleted', $matchDeleted);
$foundIds = array_column($builder->get()->getResultArray(), 'item_id');
if ($logic === 'AND') {
if (empty($matchingItemIds)) {
$matchingItemIds = $foundIds;
} else {
$matchingItemIds = array_intersect($matchingItemIds, $foundIds);
}
} else {
$matchingItemIds = array_unique(array_merge($matchingItemIds, $foundIds));
}
}
}
}
if (!empty($parsed['terms'])) {
$term = implode(' ', $parsed['terms']);
$termIds = $this->searchByAttributeValue($term, $definitionIds, $matchDeleted);
if (empty($matchingItemIds)) {
return $termIds;
}
return $logic === 'AND'
? array_values(array_intersect($matchingItemIds, $termIds))
: array_values(array_unique(array_merge($matchingItemIds, $termIds)));
}
return $matchingItemIds;
}
/**
* Search for items by a single attribute value
*
* @param string $search Search term
* @param array $definitionIds Attribute definition IDs to search within
* @param bool $matchDeleted Whether to match items where deleted flag equals this value
* @return array Array of matching item_ids
*/
private function searchByAttributeValue(string $search, array $definitionIds, bool $matchDeleted = false): array
{
$builder = $this->db->table('attribute_links');
$builder->select('DISTINCT attribute_links.item_id');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
$builder->join('items', 'items.item_id = attribute_links.item_id');
$builder->groupStart();
$builder->like('attribute_values.attribute_value', $search);
$builder->orWhere('attribute_values.attribute_decimal', $search);
$builder->orWhere('attribute_values.attribute_date', $search);
$builder->groupEnd();
$builder->whereIn('attribute_links.definition_id', $definitionIds);
$builder->where('attribute_links.sale_id', null);
$builder->where('attribute_links.receiving_id', null);
$builder->where('items.deleted', $matchDeleted);
return array_column($builder->get()->getResultArray(), 'item_id');
}
/**
* Get attribute definition ID from column name for sorting
*
* @param string $sortColumn The sort column name
* @return int|null The definition ID or null if not an attribute column
*/
private function getAttributeSortDefinitionId(string $sortColumn): ?int
{
if (!ctype_digit($sortColumn)) {
return null;
}
return (int) $sortColumn;
}
/**
* Perform a search on items
*/
public function search(string $search, array $filters, ?int $rows = 0, ?int $limit_from = 0, ?string $sort = 'items.name', ?string $order = 'asc', ?bool $count_only = false)
{
// Set default values
if ($rows == null) {
$rows = 0;
}
if ($limit_from == null) {
$limit_from = 0;
}
if ($sort == null) {
$sort = 'items.name';
}
if ($order == null) {
$order = 'asc';
}
if ($count_only == null) {
$count_only = false;
}
$rows = $rows ?? 0;
$limit_from = $limit_from ?? 0;
$sort = $sort ?? 'items.name';
$order = $order ?? 'asc';
$count_only = $count_only ?? false;
$config = config(OSPOS::class)->settings;
$builder = $this->db->table('items AS items'); // TODO: I'm not sure if it's needed to write items AS items... I think you can just get away with items
$builder = $this->db->table('items AS items');
// get_found_rows case
if ($count_only) {
$builder->select('COUNT(DISTINCT items.item_id) AS count');
} else {
@@ -212,13 +366,33 @@ class Item extends Model
: 'trans_date BETWEEN ' . $this->db->escape(rawurldecode($filters['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($filters['end_date']));
$builder->where($where);
$attributes_enabled = count($filters['definition_ids']) > 0;
$attributesEnabled = count($filters['definition_ids']) > 0;
$matchingItemIds = [];
if (!empty($search)) {
if ($attributes_enabled && $filters['search_custom']) {
$builder->havingLike('attribute_values', $search);
$builder->orHavingLike('attribute_dtvalues', $search);
$builder->orHavingLike('attribute_dvalues', $search);
if ($search !== '' && $attributesEnabled && $filters['search_custom']) {
$matchingItemIds = $this->searchByAttributes($search, $filters['definition_ids'], $filters['is_deleted']);
}
if ($search !== '') {
if ($attributesEnabled && $filters['search_custom']) {
if (empty($matchingItemIds)) {
$builder->groupStart();
$builder->like('name', $search);
$builder->orLike('item_number', $search);
$builder->orLike('items.item_id', $search);
$builder->orLike('company_name', $search);
$builder->orLike('items.category', $search);
$builder->groupEnd();
} else {
$builder->groupStart();
$builder->whereIn('items.item_id', $matchingItemIds);
$builder->orLike('name', $search);
$builder->orLike('item_number', $search);
$builder->orLike('items.item_id', $search);
$builder->orLike('company_name', $search);
$builder->orLike('items.category', $search);
$builder->groupEnd();
}
} else {
$builder->groupStart();
$builder->like('name', $search);
@@ -230,16 +404,43 @@ class Item extends Model
}
}
if ($attributes_enabled) {
if ($attributesEnabled && !$count_only) {
$format = $this->db->escape(dateformat_mysql());
$this->db->simpleQuery('SET SESSION group_concat_max_len=49152');
$builder->select('GROUP_CONCAT(DISTINCT CONCAT_WS(\'_\', definition_id, attribute_value) ORDER BY definition_id SEPARATOR \'|\') AS attribute_values');
$builder->select("GROUP_CONCAT(DISTINCT CONCAT_WS('_', definition_id, DATE_FORMAT(attribute_date, $format)) SEPARATOR '|') AS attribute_dtvalues");
$builder->select('GROUP_CONCAT(DISTINCT CONCAT_WS(\'_\', definition_id, attribute_decimal) SEPARATOR \'|\') AS attribute_dvalues');
$builder->join('attribute_links', 'attribute_links.item_id = items.item_id AND attribute_links.receiving_id IS NULL AND attribute_links.sale_id IS NULL AND definition_id IN (' . implode(',', $filters['definition_ids']) . ')', 'left');
$sanitizedIds = array_map('intval', $filters['definition_ids']);
$builder->join('attribute_links', 'attribute_links.item_id = items.item_id AND attribute_links.receiving_id IS NULL AND attribute_links.sale_id IS NULL AND definition_id IN (' . implode(',', $sanitizedIds) . ')', 'left');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id', 'left');
}
// Handle attribute column sorting
$sortDefinitionId = $this->getAttributeSortDefinitionId($sort);
if ($sortDefinitionId !== null && $attributesEnabled && !$count_only) {
$sortAlias = "sort_attr_{$sortDefinitionId}";
$builder->join("attribute_links AS {$sortAlias}", "{$sortAlias}.item_id = items.item_id AND {$sortAlias}.definition_id = {$sortDefinitionId} AND {$sortAlias}.sale_id IS NULL AND {$sortAlias}.receiving_id IS NULL", 'left');
$builder->join("attribute_values AS {$sortAlias}_val", "{$sortAlias}_val.attribute_id = {$sortAlias}.attribute_id", 'left');
// Determine the correct column to sort by based on attribute type
$attribute = model(Attribute::class);
$definitionInfo = $attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS, true);
$sortColumn = "{$sortAlias}_val.attribute_value"; // default to text
if (isset($definitionInfo[$sortDefinitionId])) {
$defType = is_array($definitionInfo[$sortDefinitionId]) ? ($definitionInfo[$sortDefinitionId]['type'] ?? TEXT) : TEXT;
if ($defType === DECIMAL) {
$sortColumn = "{$sortAlias}_val.attribute_decimal";
} elseif ($defType === DATE) {
$sortColumn = "{$sortAlias}_val.attribute_date";
}
}
$builder->orderBy($sortColumn, $order);
} else {
$builder->orderBy($sort, $order);
}
$builder->where('items.deleted', $filters['is_deleted']);
if ($filters['empty_upc']) {
@@ -261,17 +462,12 @@ class Item extends Model
$builder->whereIn('items.item_type', $non_temp);
}
// get_found_rows case
if ($count_only) {
return $builder->get()->getRow()->count;
}
// Avoid duplicated entries with same name because of inventory reporting multiple changes on the same item in the same date range
$builder->groupBy('items.item_id');
// Order by name of item by default
$builder->orderBy($sort, $order);
if ($rows > 0) {
$builder->limit($rows, $limit_from);
}

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

@@ -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

@@ -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

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