mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-05-26 01:05:07 -04:00
Compare commits
18 Commits
pr-4522
...
fix/attrib
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c2175b04 | ||
|
|
2d93a96ac9 | ||
|
|
c681dc51ed | ||
|
|
65568cf224 | ||
|
|
5cb4371344 | ||
|
|
281f402b8e | ||
|
|
c74fa7aeb5 | ||
|
|
be01555a34 | ||
|
|
abc381d35c | ||
|
|
cacd320206 | ||
|
|
a7a52f800c | ||
|
|
2f51c4ef52 | ||
|
|
def0c27a0e | ||
|
|
90c981b6b7 | ||
|
|
6ff28d8a4d | ||
|
|
70fb347fc4 | ||
|
|
2f5c0130f4 | ||
|
|
fdd6a408ec |
@@ -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'
|
||||
#
|
||||
|
||||
1
.github/workflows/build-release.yml
vendored
1
.github/workflows/build-release.yml
vendored
@@ -123,6 +123,7 @@ jobs:
|
||||
.
|
||||
!.git
|
||||
!node_modules
|
||||
include-hidden-files: true
|
||||
retention-days: 1
|
||||
|
||||
docker:
|
||||
|
||||
204
.github/workflows/deploy-pr.yml
vendored
204
.github/workflows/deploy-pr.yml
vendored
@@ -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"
|
||||
214
.github/workflows/deploy.yml
vendored
214
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
@@ -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
|
||||
|
||||
131
SECURITY.md
131
SECURITY.md
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -48,7 +48,8 @@ class OSPOS extends BaseConfig
|
||||
$this->settings = [
|
||||
'language' => 'english',
|
||||
'language_code' => 'en',
|
||||
'company' => 'Home'
|
||||
'company' => 'Home',
|
||||
'barcode_type' => 'Code39'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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')
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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" => "تحديث الميزات",
|
||||
];
|
||||
|
||||
@@ -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" => "تحديث الميزات",
|
||||
];
|
||||
|
||||
@@ -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ə",
|
||||
];
|
||||
|
||||
@@ -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" => "",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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" => "تایبەتمەندی نوێ بکەرەوە",
|
||||
];
|
||||
|
||||
@@ -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" => "",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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" => "",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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" => "",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" => "به روز کردن ویژگی",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" => "עדכן מאפיין",
|
||||
];
|
||||
|
||||
@@ -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" => "",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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" => "",
|
||||
];
|
||||
|
||||
@@ -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" => "",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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" => "Обновить атрибут",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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" => "ปรับปรุงแอตทริบิวต์",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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" => "Оновити атрибут",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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" => "更新属性",
|
||||
];
|
||||
|
||||
@@ -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" => "更新屬性",
|
||||
];
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') ?>
|
||||
|
||||
@@ -46,6 +46,7 @@ services:
|
||||
- .:/app
|
||||
environment:
|
||||
- CI_ENVIRONMENT=development
|
||||
- ALLOWED_HOSTNAMES=localhost
|
||||
- MYSQL_USERNAME=admin
|
||||
- MYSQL_PASSWORD=pointofsale
|
||||
- MYSQL_DB_NAME=ospos
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user