Compare commits

..

6 Commits

Author SHA1 Message Date
Ollama
5827f60045 fix: Address remaining CodeRabbit review comments
- Fix invalid jq string filter syntax (missing quotes around interpolation)
- Add environment validation job in deploy.yml for workflow_call input
- Add fork detection guard in deploy-pr.yml to prevent fork PR deployments

Fixes:
- deploy.yml:183-184 - jq filter syntax error
- deploy.yml:31 - unvalidated environment input in reusable workflow
- deploy-pr.yml:5 - fork PR deployments blocked by pull_request_review restrictions
- deploy-pr.yml:168-200 - jq filter syntax errors
2026-05-15 09:57:03 +02:00
Ollama
2acfb4773e fix(docker): enable .htaccess and set proper file permissions
- Enable AllowOverride in Apache config so .htaccess rewrite rules work
- Create missing item_pics upload directory with correct ownership
- Restrict .env file permissions to 640 (owner read/write, group read)
- Merged sed into existing RUN layer to minimize image size
2026-05-12 19:48:23 +02:00
Ollama
832b0e686f fix: Address CodeRabbit review comments
Security fixes:
- Use jq for JSON payload construction (prevents script injection)
- Add HMAC-SHA256 signature verification for webhook security
- Move untrusted inputs to env: blocks instead of inline interpolation

Robustness fixes:
- Add curl timeouts (--connect-timeout 10, --max-time 120)
- Fail when DEPLOY_WEBHOOK_URL is missing (was incorrectly succeeding)
- Add set -euo pipefail for error handling
- Fix required_contexts JSON array syntax (-F required_contexts[])
- Add deployment: false to prevent duplicate deployment records

Workflow improvements:
- Add concurrency groups to serialize same-environment deployments
- Remove unused skip_approval input
- Fix workflow_call inputs (removed required: true where default exists)
- Use vars.DEPLOY_URL for configurable environment URLs
2026-05-12 17:49:56 +02:00
Ollama
c26146c22b feat: Add PR-triggered staging deployment workflow
- deploy.yml: Add workflow_call for reuse from other workflows
- deploy-pr.yml: Auto-deploy to staging when PR is approved
- Uses GitHub environment protection for approval gates
- Posts deployment status as PR comment
- Image tag format: pr-{number}-{short-sha}
2026-05-12 17:47:36 +02:00
Ollama
e67f6bb290 fix: Update deploy webhook to match Docker Hub payload format
- Send payload matching Docker Hub webhook structure
- Include push_data.tag and repository.repo_name fields
- Token authentication via query string (?token=SECRET)
- Add optional DOCKER_REPO_NAME secret for custom repo
- Preserve GitHub deployment info in github_deployment field
2026-05-12 17:47:36 +02:00
Ollama
9f8eef96a7 feat: Add deployment workflow with approval gates
Adds manual deployment workflow triggered via GitHub Actions UI.
Supports production and staging environments with optional approval
gates configured via GitHub environment protection rules.

Workflow creates GitHub deployment records and calls an external
webhook to trigger the actual deployment.
2026-05-12 17:47:36 +02:00
42 changed files with 4665 additions and 5827 deletions

View File

@@ -16,9 +16,6 @@ CI_ENVIRONMENT = production
# Configure with comma-separated list of domains/subdomains: # Configure with comma-separated list of domains/subdomains:
# app.allowedHostnames = 'yourdomain.com,www.yourdomain.com' # 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: # For local development:
# app.allowedHostnames = 'localhost' # app.allowedHostnames = 'localhost'
# #

View File

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

View File

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

View File

@@ -14,16 +14,17 @@ permissions:
pull-requests: write pull-requests: write
jobs: jobs:
prepare: deploy-staging:
name: Prepare deployment name: Deploy to staging
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: > if: >
github.event.review.state == 'approved' && github.event.review.state == 'approved' &&
github.event.pull_request.head.repo.full_name == github.repository github.event.pull_request.head.repo.full_name == github.repository
outputs:
image_tag: ${{ steps.image.outputs.tag }} environment:
sha: ${{ github.event.pull_request.head.sha }} name: staging
pr_number: ${{ github.event.pull_request.number }} url: ${{ vars.DEPLOY_URL || 'https://dev.opensourcepos.org' }}
deployment: false
steps: steps:
- name: Checkout PR - name: Checkout PR
@@ -39,32 +40,156 @@ jobs:
run: | run: |
IMAGE_TAG="pr-${PR_NUMBER}-${PR_SHA:0:7}" IMAGE_TAG="pr-${PR_NUMBER}-${PR_SHA:0:7}"
echo "tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" echo "tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
deploy: - name: Create GitHub Deployment
name: Deploy to staging id: deployment
needs: prepare env:
uses: ./.github/workflows/deploy-core.yml GH_TOKEN: ${{ github.token }}
with: PR_NUMBER: ${{ github.event.pull_request.number }}
image_tag: ${{ needs.prepare.outputs.image_tag }} REF_SHA: ${{ github.event.pull_request.head.sha }}
sha: ${{ needs.prepare.outputs.sha }} run: |
description: Deploy PR #${{ needs.prepare.outputs.pr_number }} to staging set -euo pipefail
pr_number: ${{ needs.prepare.outputs.pr_number }}
secrets: inherit DEPLOYMENT_ID=$(gh api "repos/${GITHUB_REPOSITORY}/deployments" \
-X POST \
comment: -f ref="${REF_SHA}" \
name: Comment deployment status -f environment="staging" \
needs: [prepare, deploy] -f description="Deploy PR #${PR_NUMBER} to staging" \
if: always() -F auto_merge=false \
runs-on: ubuntu-latest -F required_contexts[] \
env: --jq '.id')
GH_TOKEN: ${{ github.token }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} if [ -z "$DEPLOYMENT_ID" ]; then
PR_NUMBER: ${{ needs.prepare.outputs.pr_number }} echo "::error::Failed to create deployment"
REF_SHA: ${{ needs.prepare.outputs.sha }} exit 1
STATUS: ${{ needs.deploy.outputs.status }} fi
steps: echo "deployment_id=$DEPLOYMENT_ID" >> "$GITHUB_OUTPUT"
- name: Comment on PR 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: | run: |
if [ "$STATUS" = "success" ]; then 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}" \ BODY=$(jq -nr --arg tag "$IMAGE_TAG" --arg sha "$REF_SHA" --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \

View File

@@ -7,17 +7,208 @@ on:
description: 'Docker image tag to deploy (e.g., v3.4.0, latest)' description: 'Docker image tag to deploy (e.g., v3.4.0, latest)'
required: true required: true
default: 'latest' 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: permissions:
contents: read contents: read
deployments: write deployments: write
jobs: 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: deploy:
name: Deploy to staging name: Deploy to ${{ inputs.environment }}
uses: ./.github/workflows/deploy-core.yml needs: validate-inputs
with: runs-on: ubuntu-latest
image_tag: ${{ inputs.image_tag }}
sha: ${{ github.sha }} environment:
description: Deploy image ${{ inputs.image_tag }} name: ${{ inputs.environment }}
secrets: inherit 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,14 +7,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& docker-php-ext-install mysqli bcmath intl gd \ && docker-php-ext-install mysqli bcmath intl gd \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& a2enmod rewrite && a2enmod rewrite \
&& sed -i 's/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
RUN echo "date.timezone = \"\${PHP_TIMEZONE}\"" > /usr/local/etc/php/conf.d/timezone.ini RUN echo "date.timezone = \"\${PHP_TIMEZONE}\"" > /usr/local/etc/php/conf.d/timezone.ini
WORKDIR /app WORKDIR /app
COPY --chown=www-data:www-data . /app COPY --chown=www-data:www-data . /app
RUN chmod 750 /app/writable/logs /app/writable/uploads /app/writable/cache /app/public/uploads /app/public/uploads/item_pics \ RUN chmod 770 /app/writable/uploads /app/writable/logs /app/writable/cache \
&& chmod 640 /app/writable/uploads/importCustomers.csv \ && mkdir -p /app/public/uploads/item_pics \
&& chown www-data:www-data /app/public/uploads/item_pics \
&& chmod 640 /app/.env \
&& ln -s /app/*[^public] /var/www \ && ln -s /app/*[^public] /var/www \
&& rm -rf /var/www/html \ && rm -rf /var/www/html \
&& ln -nsf /app/public /var/www/html && ln -nsf /app/public /var/www/html

View File

@@ -5,9 +5,8 @@
- [Supported Versions](#supported-versions) - [Supported Versions](#supported-versions)
- [Security Advisories](#security-advisories) - [Security Advisories](#security-advisories)
- [Reporting a Vulnerability](#reporting-a-vulnerability) - [Reporting a Vulnerability](#reporting-a-vulnerability)
- [Disclosure Process](#disclosure-process)
<!-- END doctoc generated TOC please keep comment here to allow update --> <!-- END doctoc generated TOC please keep comment here to allow auto update -->
# Security Policy # Security Policy
@@ -22,116 +21,26 @@ We release patches for security vulnerabilities.
## Security Advisories ## 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). The following security vulnerabilities have been published:
### High Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68434](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-wjm4-hfwg-5w5r) | CSRF leading to Admin Creation | 8.8 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-68147](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-xgr7-7pvw-fpmh) | Stored XSS in Return Policy | 8.1 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-66924](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-gv8j-f6gq-g59m) | Stored XSS in Item Kits | 7.2 | 2026-03-04 | 3.4.2 | @hungnqdz, @omkaryepre |
### Medium Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68658](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-32r8-8r9r-9chw) | Stored XSS in Company Name | 4.3 | 2026-01-13 | 3.4.2 | @hungnqdz |
For a complete list including draft advisories, see our [GitHub Security Advisories page](https://github.com/opensourcepos/opensourcepos/security/advisories).
## Reporting a Vulnerability ## Reporting a Vulnerability
**Option 1: GitHub Security Advisory (Preferred)** Please report (suspected) security vulnerabilities to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**.
1. Create a draft security advisory directly on GitHub: 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.
- 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. * Allowed Hostnames in the Site URL other than the hostname in the baseURL.
* If you want to accept multiple Hostnames, set this. * If you want to accept multiple Hostnames, set this.
* *
* Or via environment variable (useful for Docker/Compose): * E.g.,
* ALLOWED_HOSTNAMES=example.com,www.example.com * When your site URL ($baseURL) is 'http://example.com/', and your site
* * also accepts 'http://media.example.com/' and 'http://accounts.example.com/':
* ['media.example.com', 'accounts.example.com'] * ['media.example.com', 'accounts.example.com']
* *
* @var list<string> * @var list<string>
@@ -286,11 +286,7 @@ class App extends BaseConfig
// Solution for CodeIgniter 4 limitation: arrays cannot be set from .env // Solution for CodeIgniter 4 limitation: arrays cannot be set from .env
// See: https://github.com/codeigniter4/CodeIgniter4/issues/7311 // See: https://github.com/codeigniter4/CodeIgniter4/issues/7311
// Support both: app.allowedHostnames (from .env) and ALLOWED_HOSTNAMES (from environment/Docker) $envAllowedHostnames = getenv('app.allowedHostnames');
$envAllowedHostnames = getenv('ALLOWED_HOSTNAMES');
if ($envAllowedHostnames === false || trim($envAllowedHostnames) === '') {
$envAllowedHostnames = getenv('app.allowedHostnames');
}
if ($envAllowedHostnames !== false && trim($envAllowedHostnames) !== '') { if ($envAllowedHostnames !== false && trim($envAllowedHostnames) !== '') {
$this->allowedHostnames = array_values(array_filter( $this->allowedHostnames = array_values(array_filter(
array_map('trim', explode(',', $envAllowedHostnames)), array_map('trim', explode(',', $envAllowedHostnames)),
@@ -331,8 +327,9 @@ class App extends BaseConfig
$errorMessage = $errorMessage =
'Security: allowedHostnames is not configured. ' . 'Security: allowedHostnames is not configured. ' .
'Host header injection protection is disabled. ' . 'Host header injection protection is disabled. ' .
'Set app.allowedHostnames in your .env file or ALLOWED_HOSTNAMES environment variable. ' . 'Either set app.allowedHostnames in your .env file ' .
'Example: app.allowedHostnames = "example.com,www.example.com" ' . '(e.g., app.allowedHostnames = "example.com,www.example.com") ' .
'or configure $allowedHostnames in app/Config/App.php. ' .
'Received Host: ' . $httpHost; 'Received Host: ' . $httpHost;
// Production: Fail explicitly to prevent silent security vulnerabilities // Production: Fail explicitly to prevent silent security vulnerabilities

View File

@@ -5,7 +5,6 @@ namespace Config;
use App\Models\Appconfig; use App\Models\Appconfig;
use CodeIgniter\Cache\CacheInterface; use CodeIgniter\Cache\CacheInterface;
use CodeIgniter\Config\BaseConfig; use CodeIgniter\Config\BaseConfig;
use Config\Database;
/** /**
* This class holds the configuration options stored from the database so that on launch those settings can be cached * This class holds the configuration options stored from the database so that on launch those settings can be cached
@@ -14,7 +13,7 @@ use Config\Database;
*/ */
class OSPOS extends BaseConfig class OSPOS extends BaseConfig
{ {
public array $settings = []; public array $settings;
public string $commit_sha1 = 'dev'; // TODO: Travis scripts need to be updated to replace this with the commit hash on build public string $commit_sha1 = 'dev'; // TODO: Travis scripts need to be updated to replace this with the commit hash on build
private CacheInterface $cache; private CacheInterface $cache;
@@ -34,37 +33,27 @@ class OSPOS extends BaseConfig
if ($cache) { if ($cache) {
$this->settings = decode_array($cache); $this->settings = decode_array($cache);
return; } else {
} try {
$appconfig = model(Appconfig::class);
try { foreach ($appconfig->get_all()->getResult() as $app_config) {
$db = Database::connect(); $this->settings[$app_config->key] = $app_config->value;
}
if (!$db->tableExists('app_config')) { $this->cache->save('settings', encode_array($this->settings));
$this->settings = $this->getDefaultSettings(); } catch (\Exception $e) {
return; // Database table doesn't exist yet (migrations haven't run)
// or database connection failed. Return empty settings to
// allow migration page to display. Catches mysqli_sql_exception
// which is not a subclass of DatabaseException.
$this->settings = [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home'
];
} }
$appconfig = model(Appconfig::class);
foreach ($appconfig->get_all()->getResult() as $app_config) {
$this->settings[$app_config->key] = $app_config->value;
}
$this->cache->save('settings', encode_array($this->settings));
} catch (\Exception $e) {
$this->settings = $this->getDefaultSettings();
} }
} }
private function getDefaultSettings(): array
{
return [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home',
'barcode_type' => 'Code39'
];
}
/** /**
* @return void * @return void
*/ */
@@ -73,4 +62,4 @@ class OSPOS extends BaseConfig
$this->cache->delete('settings'); $this->cache->delete('settings');
$this->set_settings(); $this->set_settings();
} }
} }

View File

@@ -1,4 +1,4 @@
<?php <?php
use CodeIgniter\Router\RouteCollection; use CodeIgniter\Router\RouteCollection;
@@ -12,40 +12,6 @@ $routes->get('login', 'Login::index');
$routes->post('login', 'Login::index'); $routes->post('login', 'Login::index');
$routes->post('migrate', 'Login::migrate'); $routes->post('migrate', 'Login::migrate');
$routes->get('sales', 'Sales::getIndex');
$routes->get('sales/customerDisplay', 'Sales::getCustomerDisplay');
$routes->get('sales/itemSearch', 'Sales::getItemSearch');
$routes->post('sales/selectCustomer', 'Sales::postSelectCustomer');
$routes->post('sales/changeMode', 'Sales::postChangeMode');
$routes->post('sales/setComment', 'Sales::postSetComment');
$routes->post('sales/setInvoiceNumber', 'Sales::postSetInvoiceNumber');
$routes->post('sales/setPaymentType', 'Sales::postSetPaymentType');
$routes->post('sales/setPrintAfterSale', 'Sales::postSetPrintAfterSale');
$routes->post('sales/setPriceWorkOrders', 'Sales::postSetPriceWorkOrders');
$routes->post('sales/setEmailReceipt', 'Sales::postSetEmailReceipt');
$routes->post('sales/addPayment', 'Sales::postAddPayment');
$routes->post('sales/add', 'Sales::postAdd');
$routes->post('sales/editItem/(:segment)', 'Sales::postEditItem/$1');
$routes->post('sales/deleteItem/(:segment)', 'Sales::getDeleteItem/$1');
$routes->post('sales/deletePayment/(:segment)', 'Sales::getDeletePayment/$1');
$routes->post('sales/removeCustomer', 'Sales::getRemoveCustomer');
$routes->post('sales/complete', 'Sales::postComplete');
$routes->post('sales/cancel', 'Sales::postCancel');
$routes->post('sales/suspend', 'Sales::postSuspend');
$routes->post('sales/unsuspend', 'Sales::postUnsuspend');
$routes->post('sales/checkInvoiceNumber', 'Sales::postCheckInvoiceNumber');
$routes->post('sales/changeItemNumber', 'Sales::postChangeItemNumber');
$routes->post('sales/changeItemName', 'Sales::postChangeItemName');
$routes->post('sales/changeItemDescription', 'Sales::postChangeItemDescription');
$routes->get('sales/suspended', 'Sales::getSuspended');
$routes->get('sales/discardSuspendedSale', 'Sales::getDiscardSuspendedSale');
$routes->get('sales/sales_keyboard_help', 'Sales::getSalesKeyboardHelp');
$routes->get('sales/receipt/(:num)', 'Sales::getReceipt/$1');
$routes->get('sales/invoice/(:num)', 'Sales::getInvoice/$1');
$routes->get('sales/edit/(:num)', 'Sales::getEdit/$1');
$routes->post('sales/delete/(:num)', 'Sales::postDelete/$1');
$routes->post('sales/save/(:num)', 'Sales::postSave/$1');
$routes->add('no_access/index/(:segment)', 'No_access::index/$1'); $routes->add('no_access/index/(:segment)', 'No_access::index/$1');
$routes->add('no_access/index/(:segment)/(:segment)', 'No_access::index/$1/$2'); $routes->add('no_access/index/(:segment)/(:segment)', 'No_access::index/$1/$2');
@@ -73,4 +39,4 @@ $routes->add('reports/specific_(:any)/(:any)/(:any)/(:any)', 'Reports::Specific_
$routes->add('reports/specific_customers', 'Reports::specific_customer_input'); $routes->add('reports/specific_customers', 'Reports::specific_customer_input');
$routes->add('reports/specific_employees', 'Reports::specific_employee_input'); $routes->add('reports/specific_employees', 'Reports::specific_employee_input');
$routes->add('reports/specific_discounts', 'Reports::specific_discount_input'); $routes->add('reports/specific_discounts', 'Reports::specific_discount_input');
$routes->add('reports/specific_suppliers', 'Reports::specific_supplier_input'); $routes->add('reports/specific_suppliers', 'Reports::specific_supplier_input');

View File

File diff suppressed because it is too large Load Diff

View File

@@ -154,23 +154,8 @@ class Items extends Secure_Controller
{ {
helper('file'); helper('file');
// Security: Sanitize filename to prevent path traversal $pic_filename = rawurldecode($pic_filename);
// Use basename() to strip directory components and prevent '../' attacks $file_extension = pathinfo($pic_filename, PATHINFO_EXTENSION);
$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"); $images = glob("./uploads/item_pics/$pic_filename");
$base_path = './uploads/item_pics/' . pathinfo($pic_filename, PATHINFO_FILENAME); $base_path = './uploads/item_pics/' . pathinfo($pic_filename, PATHINFO_FILENAME);
@@ -1055,20 +1040,14 @@ class Items extends Secure_Controller
}); });
if (!$isFailedRow && $this->item->save_value($itemData, $itemId)) { if (!$isFailedRow && $this->item->save_value($itemData, $itemId)) {
if (!$this->save_tax_data($row, $itemData)) { $this->save_tax_data($row, $itemData);
$isFailedRow = true; $this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId);
}
if (!$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId)) {
$isFailedRow = true;
}
$csvAttributeValues = $this->extractAttributeData($row); $csvAttributeValues = $this->extractAttributeData($row);
if (!$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData)) { $isFailedRow = !$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData);
$isFailedRow = true;
}
if ($isFailedRow) { if ($isFailedRow) {
$failedRow = $key + 2; $failedRow = $key + 2;
$failCodes[] = $failedRow; $failCodes[] = $failedRow;
log_message('error', "CSV Item import failed on line $failedRow while saving item."); log_message('error', "CSV Item import failed on line $failedRow while saving attributes.");
continue; continue;
} }
@@ -1258,15 +1237,13 @@ class Items extends Secure_Controller
* @param array $item_data * @param array $item_data
* @param array $allowed_locations * @param array $allowed_locations
* @param int $employee_id * @param int $employee_id
* @return bool Returns true on success, false on failure
* @throws ReflectionException * @throws ReflectionException
*/ */
private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): bool private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): void
{ {
// Quantities & Inventory Section // Quantities & Inventory Section
$comment = lang('Items.inventory_CSV_import_quantity'); $comment = lang('Items.inventory_CSV_import_quantity');
$is_update = (bool)$row['Id']; $is_update = (bool)$row['Id'];
$success = true;
foreach ($allowed_locations as $location_id => $location_name) { foreach ($allowed_locations as $location_id => $location_name) {
$item_quantity_data = ['item_id' => $item_data['item_id'], 'location_id' => $location_id]; $item_quantity_data = ['item_id' => $item_data['item_id'], 'location_id' => $location_id];
@@ -1280,22 +1257,20 @@ class Items extends Secure_Controller
if (!empty($row["location_$location_name"]) || $row["location_$location_name"] === '0') { if (!empty($row["location_$location_name"]) || $row["location_$location_name"] === '0') {
$item_quantity_data['quantity'] = $row["location_$location_name"]; $item_quantity_data['quantity'] = $row["location_$location_name"];
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id); $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = $row["location_$location_name"]; $csv_data['trans_inventory'] = $row["location_$location_name"];
$success &= (bool)$this->inventory->insert($csv_data, false); $this->inventory->insert($csv_data, false);
} elseif ($is_update) { } elseif ($is_update) {
continue; return;
} else { } else {
$item_quantity_data['quantity'] = 0; $item_quantity_data['quantity'] = 0;
$success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id); $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = 0; $csv_data['trans_inventory'] = 0;
$success &= (bool)$this->inventory->insert($csv_data, false); $this->inventory->insert($csv_data, false);
} }
} }
return (bool)$success;
} }
/** /**
@@ -1303,9 +1278,8 @@ class Items extends Secure_Controller
* *
* @param array $row * @param array $row
* @param array $item_data * @param array $item_data
* @return bool Returns true on success, false on failure
*/ */
private function save_tax_data(array $row, array $item_data): bool private function save_tax_data(array $row, array $item_data): void
{ {
$items_taxes_data = []; $items_taxes_data = [];
@@ -1317,11 +1291,9 @@ class Items extends Secure_Controller
$items_taxes_data[] = ['name' => $row['Tax 2 Name'], 'percent' => $row['Tax 2 Percent']]; $items_taxes_data[] = ['name' => $row['Tax 2 Name'], 'percent' => $row['Tax 2 Percent']];
} }
if (!empty($items_taxes_data)) { if (isset($items_taxes_data)) {
return $this->item_taxes->save_value($items_taxes_data, $item_data['item_id']); $this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
} }
return true;
} }
/** /**

View File

@@ -49,13 +49,6 @@ class Login extends BaseController
return view('login', $data); return view('login', $data);
} }
if (!$data['is_latest'] || $data['is_new_install']) {
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return redirect()->to('login');
}
$rules = ['username' => 'required|login_check[data]']; $rules = ['username' => 'required|login_check[data]'];
$messages = [ $messages = [
'username' => [ 'username' => [
@@ -69,6 +62,13 @@ class Login extends BaseController
return view('login', $data); return view('login', $data);
} }
if (!$data['is_latest']) {
set_time_limit(3600);
$migration->setNamespace('App')->latest();
return redirect()->to('login');
}
} }
return redirect()->to('home'); return redirect()->to('home');
@@ -79,18 +79,18 @@ class Login extends BaseController
try { try {
$migration = new MY_Migration(config('Migrations')); $migration = new MY_Migration(config('Migrations'));
$migration->migrate_to_ci4(); $migration->migrate_to_ci4();
set_time_limit(3600); set_time_limit(3600);
$migration->setNamespace('App')->latest(); $migration->setNamespace('App')->latest();
return $this->response->setJSON([ return $this->response->setJSON([
'success' => true, 'success' => true,
'message' => 'Migration completed successfully' 'message' => 'Migration completed successfully'
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
log_message('error', 'Migration failed: ' . $e->getMessage()); log_message('error', 'Migration failed: ' . $e->getMessage());
return $this->response->setJSON([ return $this->response->setJSON([
'success' => false, 'success' => false,
'message' => 'Migration failed: ' . $e->getMessage() 'message' => 'Migration failed: ' . $e->getMessage()

View File

@@ -1246,15 +1246,13 @@ class Reports extends Secure_Controller
public function get_payment_type(): array public function get_payment_type(): array
{ {
return [ return [
'all' => lang('Common.none_selected_text'), 'all' => lang('Common.none_selected_text'),
'cash' => lang('Sales.cash'), 'cash' => lang('Sales.cash'),
'due' => lang('Sales.due'), 'due' => lang('Sales.due'),
'check' => lang('Sales.check'), 'check' => lang('Sales.check'),
'credit' => lang('Sales.credit'), 'credit' => lang('Sales.credit'),
'debit' => lang('Sales.debit'), 'debit' => lang('Sales.debit'),
'bank_transfer' => lang('Sales.bank_transfer'), 'invoices' => lang('Sales.invoice')
'wallet' => lang('Sales.wallet'),
'invoices' => lang('Sales.invoice')
]; ];
} }

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -327,6 +327,19 @@ INSERT INTO `ospos_sales_items` (sale_id, item_id, description, serialnumber, li
INSERT INTO `ospos_sales_payments` (sale_id, payment_type, payment_amount) SELECT sale_id, payment_type, payment_amount FROM `ospos_sales_suspended_payments`; INSERT INTO `ospos_sales_payments` (sale_id, payment_type, payment_amount) SELECT sale_id, payment_type, payment_amount FROM `ospos_sales_suspended_payments`;
INSERT INTO `ospos_sales_items_taxes` (sale_id, item_id, line, name, percent) SELECT sale_id, item_id, line, name, percent FROM `ospos_sales_suspended_items_taxes`; INSERT INTO `ospos_sales_items_taxes` (sale_id, item_id, line, name, percent) SELECT sale_id, item_id, line, name, percent FROM `ospos_sales_suspended_items_taxes`;
ALTER TABLE `ospos_sales_suspended_payments` DROP FOREIGN KEY `ospos_sales_suspended_payments_ibfk_1`;
ALTER TABLE `ospos_sales_suspended_items_taxes` DROP FOREIGN KEY `ospos_sales_suspended_items_taxes_ibfk_1`;
ALTER TABLE `ospos_sales_suspended_items_taxes` DROP FOREIGN KEY `ospos_sales_suspended_items_taxes_ibfk_2`;
ALTER TABLE `ospos_sales_suspended_items` DROP FOREIGN KEY `ospos_sales_suspended_items_ibfk_1`;
ALTER TABLE `ospos_sales_suspended_items` DROP FOREIGN KEY `ospos_sales_suspended_items_ibfk_2`;
ALTER TABLE `ospos_sales_suspended_items` DROP FOREIGN KEY `ospos_sales_suspended_items_ibfk_3`;
ALTER TABLE `ospos_sales_suspended` DROP FOREIGN KEY `ospos_sales_suspended_ibfk_1`;
ALTER TABLE `ospos_sales_suspended` DROP FOREIGN KEY `ospos_sales_suspended_ibfk_2`;
ALTER TABLE `ospos_sales_suspended` DROP FOREIGN KEY `ospos_sales_suspended_ibfk_3`;
DROP TABLE `ospos_sales_suspended_payments`, `ospos_sales_suspended_items_taxes`, `ospos_sales_suspended_items`, `ospos_sales_suspended`; DROP TABLE `ospos_sales_suspended_payments`, `ospos_sales_suspended_items_taxes`, `ospos_sales_suspended_items`, `ospos_sales_suspended`;
-- --

View File

@@ -140,7 +140,7 @@ CREATE TABLE IF NOT EXISTS `ospos_expense_categories` (
`category_name` varchar(255) DEFAULT NULL, `category_name` varchar(255) DEFAULT NULL,
`category_description` varchar(255) NOT NULL, `category_description` varchar(255) NOT NULL,
`deleted` int(1) NOT NULL DEFAULT '0' `deleted` int(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- Table structure for table `ospos_expenses` -- Table structure for table `ospos_expenses`
@@ -154,7 +154,7 @@ CREATE TABLE IF NOT EXISTS `ospos_expenses` (
`description` varchar(255) NOT NULL, `description` varchar(255) NOT NULL,
`employee_id` int(10) NOT NULL, `employee_id` int(10) NOT NULL,
`deleted` int(1) NOT NULL DEFAULT '0' `deleted` int(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- Indexes for table `ospos_expense_categories` -- Indexes for table `ospos_expense_categories`

View File

@@ -75,7 +75,7 @@ CREATE TABLE `ospos_cash_up` (
`open_employee_id` int(10) NOT NULL, `open_employee_id` int(10) NOT NULL,
`close_employee_id` int(10) NOT NULL, `close_employee_id` int(10) NOT NULL,
`deleted` int(1) NOT NULL DEFAULT '0' `deleted` int(1) NOT NULL DEFAULT '0'
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- Indexes for table `ospos_cash_up` -- Indexes for table `ospos_cash_up`

View File

@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS `ospos_tax_codes` (
`state` varchar(255) NOT NULL DEFAULT '', `state` varchar(255) NOT NULL DEFAULT '',
`deleted` int(1) NOT NULL DEFAULT 0, `deleted` int(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`tax_code_id`) PRIMARY KEY (`tax_code_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
ALTER TABLE `ospos_customers` ALTER TABLE `ospos_customers`
ADD COLUMN `tax_id` varchar(32) NOT NULL DEFAULT '' AFTER `taxable`, ADD COLUMN `tax_id` varchar(32) NOT NULL DEFAULT '' AFTER `taxable`,
@@ -59,7 +59,7 @@ CREATE TABLE `ospos_sales_taxes` (
`rounding_code` tinyint(2) NOT NULL DEFAULT 0, `rounding_code` tinyint(2) NOT NULL DEFAULT 0,
PRIMARY KEY (`sales_taxes_id`), PRIMARY KEY (`sales_taxes_id`),
KEY `print_sequence` (`sale_id`,`print_sequence`,`tax_group`) KEY `print_sequence` (`sale_id`,`print_sequence`,`tax_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `ospos_tax_jurisdictions` ( CREATE TABLE IF NOT EXISTS `ospos_tax_jurisdictions` (
`jurisdiction_id` int(11) NOT NULL AUTO_INCREMENT, `jurisdiction_id` int(11) NOT NULL AUTO_INCREMENT,
@@ -71,7 +71,7 @@ CREATE TABLE IF NOT EXISTS `ospos_tax_jurisdictions` (
`cascade_sequence` tinyint(2) NOT NULL DEFAULT 0, `cascade_sequence` tinyint(2) NOT NULL DEFAULT 0,
`deleted` int(1) NOT NULL DEFAULT 0, `deleted` int(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`jurisdiction_id`) PRIMARY KEY (`jurisdiction_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci AUTO_INCREMENT=1; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
ALTER TABLE `ospos_suppliers` ALTER TABLE `ospos_suppliers`
ADD COLUMN `tax_id` varchar(32) DEFAULT NULL AFTER `account_number`; ADD COLUMN `tax_id` varchar(32) DEFAULT NULL AFTER `account_number`;
@@ -89,7 +89,7 @@ CREATE TABLE IF NOT EXISTS `ospos_tax_rates` (
`tax_rate` decimal(15,4) NOT NULL DEFAULT 0.0000, `tax_rate` decimal(15,4) NOT NULL DEFAULT 0.0000,
`tax_rounding_code` tinyint(2) NOT NULL DEFAULT 0, `tax_rounding_code` tinyint(2) NOT NULL DEFAULT 0,
PRIMARY KEY (`tax_rate_id`) PRIMARY KEY (`tax_rate_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- Add support for sales tax report -- Add support for sales tax report

View File

@@ -12,7 +12,7 @@ CREATE TABLE `ospos_sales_payments` (
`reference_code` varchar(40) NOT NULL DEFAULT '', `reference_code` varchar(40) NOT NULL DEFAULT '',
PRIMARY KEY (`payment_id`), PRIMARY KEY (`payment_id`),
KEY `payment_sale` (`sale_id`, `payment_type`) KEY `payment_sale` (`sale_id`, `payment_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO ospos_sales_payments (sale_id, payment_type, payment_amount, payment_user) INSERT INTO ospos_sales_payments (sale_id, payment_type, payment_amount, payment_user)
SELECT payments.sale_id, payments.payment_type, payments.payment_amount, sales.employee_id SELECT payments.sale_id, payments.payment_type, payments.payment_amount, sales.employee_id

View File

@@ -365,74 +365,6 @@ function to_currency_no_money(?string $number): string
return to_decimals($number, 'currency_decimals'); return to_decimals($number, 'currency_decimals');
} }
/**
* Build the secondary currency rendering context from app config values.
*
* @param array $config
* @return array{show:bool,rate:float,symbol:string,code:string,decimals:int}
*/
function secondary_currency_context(array $config): array
{
$rate = (float) ($config['secondary_currency_rate'] ?? 0);
$symbol = trim((string) ($config['secondary_currency_symbol'] ?? ''));
$code = trim((string) ($config['secondary_currency_code'] ?? ''));
$decimals = (int) ($config['secondary_currency_decimals'] ?? ($config['currency_decimals'] ?? DEFAULT_PRECISION));
return [
'show' => (($config['secondary_currency_enabled'] ?? false) == 1) && $rate > 0,
'rate' => $rate,
'symbol' => $symbol,
'code' => $code,
'decimals' => $decimals,
];
}
/**
* Render a value in the secondary currency.
*
* @param float|int|string|null $number
* @param array{show:bool,rate:float,symbol:string,code:string,decimals:int} $secondaryCurrency
* @return string
*/
function to_secondary_currency(float|int|string|null $number, array $secondaryCurrency): string
{
if (!isset($number) || !$secondaryCurrency['show']) {
return '';
}
$config = config(OSPOS::class)->settings;
$amount = (float) $number * (float) $secondaryCurrency['rate'];
$fmt = new NumberFormatter($config['number_locale'], NumberFormatter::CURRENCY);
$fmt->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $secondaryCurrency['decimals']);
$fmt->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $secondaryCurrency['decimals']);
if (empty($config['thousands_separator'])) {
$fmt->setTextAttribute(NumberFormatter::GROUPING_SEPARATOR_SYMBOL, '');
}
$fmt->setSymbol(NumberFormatter::CURRENCY_SYMBOL, $secondaryCurrency['symbol'] !== '' ? $secondaryCurrency['symbol'] : ($secondaryCurrency['code'] !== '' ? $secondaryCurrency['code'] : ''));
return $fmt->format($amount);
}
/**
* Render the secondary and primary currency amounts together.
*
* @param float|int|string|null $number
* @param array{show:bool,rate:float,symbol:string,code:string,decimals:int} $secondaryCurrency
* @return string
*/
function to_secondary_currency_dual(float|int|string|null $number, array $secondaryCurrency): string
{
$secondary = to_secondary_currency($number, $secondaryCurrency);
if ($secondary === '') {
return to_currency((string) $number);
}
return $secondary . '<br>' . to_currency((string) $number);
}
/** /**
* @param string|null $number * @param string|null $number
* @return string * @return string

View File

@@ -172,7 +172,6 @@ function dropAllForeignKeyConstraints(string $table, string $column): array {
WHERE kcu.TABLE_SCHEMA = DATABASE() WHERE kcu.TABLE_SCHEMA = DATABASE()
AND ((kcu.REFERENCED_TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.REFERENCED_COLUMN_NAME = '$column') AND ((kcu.REFERENCED_TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.REFERENCED_COLUMN_NAME = '$column')
OR (kcu.TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.COLUMN_NAME = '$column')) OR (kcu.TABLE_NAME = '" . $db->getPrefix() . "$table' AND kcu.COLUMN_NAME = '$column'))
AND rc.CONSTRAINT_NAME IS NOT NULL
"); ");
$deletedConstraints = []; $deletedConstraints = [];

View File

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

View File

@@ -1,344 +1,336 @@
<?php <?php
return [ return [
"address" => "Company Address", "address" => "Company Address",
"address_required" => "Company address is a required field.", "address_required" => "Company address is a required field.",
"all_set" => "All file permissions are set correctly!", "all_set" => "All file permissions are set correctly!",
"allow_duplicate_barcodes" => "Allow Duplicate Barcodes", "allow_duplicate_barcodes" => "Allow Duplicate Barcodes",
"apostrophe" => "apostrophe", "apostrophe" => "apostrophe",
"backup_button" => "Backup", "backup_button" => "Backup",
"backup_database" => "Backup Database", "backup_database" => "Backup Database",
"barcode" => "Barcode", "barcode" => "Barcode",
"barcode_company" => "Company Name", "barcode_company" => "Company Name",
"barcode_configuration" => "Barcode Configuration", "barcode_configuration" => "Barcode Configuration",
"barcode_content" => "Barcode Content", "barcode_content" => "Barcode Content",
"barcode_first_row" => "Row 1", "barcode_first_row" => "Row 1",
"barcode_font" => "Font", "barcode_font" => "Font",
"barcode_formats" => "Input Formats", "barcode_formats" => "Input Formats",
"barcode_generate_if_empty" => "Generate if empty.", "barcode_generate_if_empty" => "Generate if empty.",
"barcode_height" => "Height (px)", "barcode_height" => "Height (px)",
"barcode_id" => "Item Id/Name", "barcode_id" => "Item Id/Name",
"barcode_info" => "Barcode Configuration Information", "barcode_info" => "Barcode Configuration Information",
"barcode_layout" => "Barcode Layout", "barcode_layout" => "Barcode Layout",
"barcode_name" => "Name", "barcode_name" => "Name",
"barcode_number" => "Barcode", "barcode_number" => "Barcode",
"barcode_number_in_row" => "Number in row", "barcode_number_in_row" => "Number in row",
"barcode_page_cellspacing" => "Display page cellspacing.", "barcode_page_cellspacing" => "Display page cellspacing.",
"barcode_page_width" => "Display page width", "barcode_page_width" => "Display page width",
"barcode_price" => "Price", "barcode_price" => "Price",
"barcode_second_row" => "Row 2", "barcode_second_row" => "Row 2",
"barcode_third_row" => "Row 3", "barcode_third_row" => "Row 3",
"barcode_tooltip" => "Warning: This feature can cause duplicate items to be imported or created. Do not use if you do not want duplicate barcodes.", "barcode_tooltip" => "Warning: This feature can cause duplicate items to be imported or created. Do not use if you do not want duplicate barcodes.",
"barcode_type" => "Barcode Type", "barcode_type" => "Barcode Type",
"barcode_width" => "Width (px)", "barcode_width" => "Width (px)",
"bottom" => "Bottom", "bottom" => "Bottom",
"cash_button" => "", "cash_button" => "",
"cash_button_1" => "", "cash_button_1" => "",
"cash_button_2" => "", "cash_button_2" => "",
"cash_button_3" => "", "cash_button_3" => "",
"cash_button_4" => "", "cash_button_4" => "",
"cash_button_5" => "", "cash_button_5" => "",
"cash_button_6" => "", "cash_button_6" => "",
"cash_decimals" => "Cash Decimals", "cash_decimals" => "Cash Decimals",
"cash_decimals_tooltip" => "If Cash Decimals and Currency Decimals are the same then no cash triggered rounding will take place, unless Cash Rounding is set to Half Five.", "cash_decimals_tooltip" => "If Cash Decimals and Currency Decimals are the same then no cash triggered rounding will take place, unless Cash Rounding is set to Half Five.",
"cash_rounding" => "Cash Rounding", "cash_rounding" => "Cash Rounding",
"category_dropdown" => "Show Category as a dropdown", "category_dropdown" => "Show Category as a dropdown",
"center" => "Center", "center" => "Center",
"change_apperance_tooltip" => "", "change_apperance_tooltip" => "",
"comma" => "comma", "comma" => "comma",
"company" => "Company Name", "company" => "Company Name",
"company_avatar" => "", "company_avatar" => "",
"company_change_image" => "Change Image", "company_change_image" => "Change Image",
"company_logo" => "Company Logo", "company_logo" => "Company Logo",
"company_remove_image" => "Remove Image", "company_remove_image" => "Remove Image",
"company_required" => "Company name is a required field", "company_required" => "Company name is a required field",
"company_select_image" => "Select Image", "company_select_image" => "Select Image",
"company_website_url" => "Company website is not a valid URL (http://...).", "company_website_url" => "Company website is not a valid URL (http://...).",
"country_codes" => "Country Codes", "country_codes" => "Country Codes",
"country_codes_tooltip" => "Comma separated list of country codes for nominatim address lookup.", "country_codes_tooltip" => "Comma separated list of country codes for nominatim address lookup.",
"currency_code" => "Currency Code", "currency_code" => "Currency Code",
"currency_decimals" => "Currency Decimals", "currency_decimals" => "Currency Decimals",
"currency_symbol" => "Currency Symbol", "currency_symbol" => "Currency Symbol",
"current_employee_only" => "", "current_employee_only" => "",
"customer_reward" => "Reward", "customer_reward" => "Reward",
"customer_reward_duplicate" => "Reward must be unique.", "customer_reward_duplicate" => "Reward must be unique.",
"customer_reward_enable" => "Enable Customer Rewards", "customer_reward_enable" => "Enable Customer Rewards",
"customer_reward_invalid_chars" => "Reward can not contain '_'", "customer_reward_invalid_chars" => "Reward can not contain '_'",
"customer_reward_required" => "Reward is a required field", "customer_reward_required" => "Reward is a required field",
"customer_sales_tax_support" => "", "customer_sales_tax_support" => "",
"date_or_time_format" => "Date and Time Filter", "date_or_time_format" => "Date and Time Filter",
"datetimeformat" => "Date and Time Format", "datetimeformat" => "Date and Time Format",
"decimal_point" => "Decimal Point", "decimal_point" => "Decimal Point",
"default_barcode_font_size_number" => "Default Barcode Font Size must be a number.", "default_barcode_font_size_number" => "Default Barcode Font Size must be a number.",
"default_barcode_font_size_required" => "Default Barcode Font Size is a required field.", "default_barcode_font_size_required" => "Default Barcode Font Size is a required field.",
"default_barcode_height_number" => "Default Barcode Height must be a number.", "default_barcode_height_number" => "Default Barcode Height must be a number.",
"default_barcode_height_required" => "Default Barcode Height is a required field.", "default_barcode_height_required" => "Default Barcode Height is a required field.",
"default_barcode_num_in_row_number" => "Default Barcode Number in Row must be a number.", "default_barcode_num_in_row_number" => "Default Barcode Number in Row must be a number.",
"default_barcode_num_in_row_required" => "Default Barcode Number in Row is a required field.", "default_barcode_num_in_row_required" => "Default Barcode Number in Row is a required field.",
"default_barcode_page_cellspacing_number" => "Default Barcode Page Cellspacing must be a number.", "default_barcode_page_cellspacing_number" => "Default Barcode Page Cellspacing must be a number.",
"default_barcode_page_cellspacing_required" => "Default Barcode Page Cellspacing is a required field.", "default_barcode_page_cellspacing_required" => "Default Barcode Page Cellspacing is a required field.",
"default_barcode_page_width_number" => "Default Barcode Page Width must be a number.", "default_barcode_page_width_number" => "Default Barcode Page Width must be a number.",
"default_barcode_page_width_required" => "Default Barcode Page Width is a required field.", "default_barcode_page_width_required" => "Default Barcode Page Width is a required field.",
"default_barcode_width_number" => "Default Barcode Width must be a number.", "default_barcode_width_number" => "Default Barcode Width must be a number.",
"default_barcode_width_required" => "Default Barcode Width is a required field.", "default_barcode_width_required" => "Default Barcode Width is a required field.",
"default_item_columns" => "Default Visible Item Columns", "default_item_columns" => "Default Visible Item Columns",
"default_origin_tax_code" => "Default Origin Tax Code", "default_origin_tax_code" => "Default Origin Tax Code",
"default_receivings_discount" => "Default Receivings Discount", "default_receivings_discount" => "Default Receivings Discount",
"default_receivings_discount_number" => "Default Receivings Discount must be a number.", "default_receivings_discount_number" => "Default Receivings Discount must be a number.",
"default_receivings_discount_required" => "Default Receivings Discount is a required field.", "default_receivings_discount_required" => "Default Receivings Discount is a required field.",
"default_sales_discount" => "Default Sales Discount", "default_sales_discount" => "Default Sales Discount",
"default_sales_discount_number" => "Default Sales Discount must be a number.", "default_sales_discount_number" => "Default Sales Discount must be a number.",
"default_sales_discount_required" => "Default Sales Discount is a required field.", "default_sales_discount_required" => "Default Sales Discount is a required field.",
"default_tax_category" => "Default Tax Category", "default_tax_category" => "Default Tax Category",
"default_tax_code" => "Default Tax Code", "default_tax_code" => "Default Tax Code",
"default_tax_jurisdiction" => "Default Tax Jurisdiction", "default_tax_jurisdiction" => "Default Tax Jurisdiction",
"default_tax_name_number" => "Default Tax Name must be a string.", "default_tax_name_number" => "Default Tax Name must be a string.",
"default_tax_name_required" => "Default Tax Name is a required field.", "default_tax_name_required" => "Default Tax Name is a required field.",
"default_tax_rate" => "Default Tax Rate %", "default_tax_rate" => "Default Tax Rate %",
"default_tax_rate_1" => "Tax 1 Rate", "default_tax_rate_1" => "Tax 1 Rate",
"default_tax_rate_2" => "Tax 2 Rate", "default_tax_rate_2" => "Tax 2 Rate",
"default_tax_rate_3" => "", "default_tax_rate_3" => "",
"default_tax_rate_number" => "Default Tax Rate must be a number.", "default_tax_rate_number" => "Default Tax Rate must be a number.",
"default_tax_rate_required" => "Default Tax Rate is a required field.", "default_tax_rate_required" => "Default Tax Rate is a required field.",
"derive_sale_quantity" => "Allow Derived Sale Quantity", "derive_sale_quantity" => "Allow Derived Sale Quantity",
"derive_sale_quantity_tooltip" => "If checked then a new item type will be provided for items ordered by extended amount", "derive_sale_quantity_tooltip" => "If checked then a new item type will be provided for items ordered by extended amount",
"dinner_table" => "Table", "dinner_table" => "Table",
"dinner_table_duplicate" => "Table must be unique.", "dinner_table_duplicate" => "Table must be unique.",
"dinner_table_enable" => "Enable Dinner Tables", "dinner_table_enable" => "Enable Dinner Tables",
"dinner_table_invalid_chars" => "Table Name can not contain '_'.", "dinner_table_invalid_chars" => "Table Name can not contain '_'.",
"dinner_table_required" => "Table is a required field.", "dinner_table_required" => "Table is a required field.",
"dot" => "dot", "dot" => "dot",
"email" => "Email", "email" => "Email",
"email_configuration" => "Email Configuration", "email_configuration" => "Email Configuration",
"email_mailpath" => "Path to Sendmail", "email_mailpath" => "Path to Sendmail",
"email_protocol" => "Protocol", "email_protocol" => "Protocol",
"email_receipt_check_behaviour" => "Email Receipt checkbox", "email_receipt_check_behaviour" => "Email Receipt checkbox",
"email_receipt_check_behaviour_always" => "Always checked", "email_receipt_check_behaviour_always" => "Always checked",
"email_receipt_check_behaviour_last" => "Remember last selection", "email_receipt_check_behaviour_last" => "Remember last selection",
"email_receipt_check_behaviour_never" => "Always unchecked", "email_receipt_check_behaviour_never" => "Always unchecked",
"email_smtp_crypto" => "SMTP Encryption", "email_smtp_crypto" => "SMTP Encryption",
"email_smtp_host" => "SMTP Server", "email_smtp_host" => "SMTP Server",
"email_smtp_pass" => "SMTP Password", "email_smtp_pass" => "SMTP Password",
"email_smtp_port" => "SMTP Port", "email_smtp_port" => "SMTP Port",
"email_smtp_timeout" => "SMTP Timeout (s)", "email_smtp_timeout" => "SMTP Timeout (s)",
"email_smtp_user" => "SMTP Username", "email_smtp_user" => "SMTP Username",
"enable_avatar" => "", "enable_avatar" => "",
"enable_avatar_tooltip" => "", "enable_avatar_tooltip" => "",
"enable_dropdown_tooltip" => "", "enable_dropdown_tooltip" => "",
"enable_new_look" => "", "enable_new_look" => "",
"enable_right_bar" => "", "enable_right_bar" => "",
"enable_right_bar_tooltip" => "", "enable_right_bar_tooltip" => "",
"enforce_privacy" => "Enforce privacy", "enforce_privacy" => "Enforce privacy",
"enforce_privacy_tooltip" => "Protect Customers privacy enforcing data scrambling in case of their data being deleted", "enforce_privacy_tooltip" => "Protect Customers privacy enforcing data scrambling in case of their data being deleted",
"fax" => "Fax", "fax" => "Fax",
"file_perm" => "There are problems with file permissions. Please fix and reload this page.", "file_perm" => "There are problems with file permissions. Please fix and reload this page.",
"financial_year" => "Fiscal Year Start", "financial_year" => "Fiscal Year Start",
"financial_year_apr" => "1st of April", "financial_year_apr" => "1st of April",
"financial_year_aug" => "1st of August", "financial_year_aug" => "1st of August",
"financial_year_dec" => "1st of December", "financial_year_dec" => "1st of December",
"financial_year_feb" => "1st of February", "financial_year_feb" => "1st of February",
"financial_year_jan" => "1st of January", "financial_year_jan" => "1st of January",
"financial_year_jul" => "1st of July", "financial_year_jul" => "1st of July",
"financial_year_jun" => "1st of June", "financial_year_jun" => "1st of June",
"financial_year_mar" => "1st of March", "financial_year_mar" => "1st of March",
"financial_year_may" => "1st of May", "financial_year_may" => "1st of May",
"financial_year_nov" => "1st of November", "financial_year_nov" => "1st of November",
"financial_year_oct" => "1st of October", "financial_year_oct" => "1st of October",
"financial_year_sep" => "1st of September", "financial_year_sep" => "1st of September",
"floating_labels" => "Floating Labels", "floating_labels" => "Floating Labels",
"gcaptcha_enable" => "Login Page reCAPTCHA", "gcaptcha_enable" => "Login Page reCAPTCHA",
"gcaptcha_secret_key" => "reCAPTCHA Secret Key", "gcaptcha_secret_key" => "reCAPTCHA Secret Key",
"gcaptcha_secret_key_required" => "reCAPTCHA Secret Key is a required field", "gcaptcha_secret_key_required" => "reCAPTCHA Secret Key is a required field",
"gcaptcha_site_key" => "reCAPTCHA Site Key", "gcaptcha_site_key" => "reCAPTCHA Site Key",
"gcaptcha_site_key_required" => "reCAPTCHA Site Key is a required field", "gcaptcha_site_key_required" => "reCAPTCHA Site Key is a required field",
"gcaptcha_tooltip" => "Protect the Login page with Google reCAPTCHA, click the icon for an API key pair.", "gcaptcha_tooltip" => "Protect the Login page with Google reCAPTCHA, click the icon for an API key pair.",
"general" => "General", "general" => "General",
"general_configuration" => "General Configuration", "general_configuration" => "General Configuration",
"giftcard_number" => "Gift Card Number", "giftcard_number" => "Gift Card Number",
"giftcard_random" => "Generate Random", "giftcard_random" => "Generate Random",
"giftcard_series" => "Generate in Series", "giftcard_series" => "Generate in Series",
"image_allowed_file_types" => "Allowed file types", "image_allowed_file_types" => "Allowed file types",
"image_max_height_tooltip" => "Maximum allowed height of image uploads in pixels (px).", "image_max_height_tooltip" => "Maximum allowed height of image uploads in pixels (px).",
"image_max_size_tooltip" => "Maximum allowed file size of image uploads in kilobytes (kb).", "image_max_size_tooltip" => "Maximum allowed file size of image uploads in kilobytes (kb).",
"image_max_width_tooltip" => "Maximum allowed width of image uploads in pixels (px).", "image_max_width_tooltip" => "Maximum allowed width of image uploads in pixels (px).",
"image_restrictions" => "Image Upload Restrictions", "image_restrictions" => "Image Upload Restrictions",
"include_hsn" => "Include Support for HSN Codes", "include_hsn" => "Include Support for HSN Codes",
"info" => "Information", "info" => "Information",
"info_configuration" => "Store Information", "info_configuration" => "Store Information",
"input_groups" => "Input Groups", "input_groups" => "Input Groups",
"integrations" => "Integrations", "integrations" => "Integrations",
"integrations_configuration" => "Third Party Integrations", "integrations_configuration" => "Third Party Integrations",
"invoice" => "Invoice", "invoice" => "Invoice",
"invoice_configuration" => "Invoice Print Settings", "invoice_configuration" => "Invoice Print Settings",
"invoice_default_comments" => "Default Invoice Comments", "invoice_default_comments" => "Default Invoice Comments",
"invoice_email_message" => "Invoice Email Template", "invoice_email_message" => "Invoice Email Template",
"invoice_enable" => "Enable Invoicing", "invoice_enable" => "Enable Invoicing",
"invoice_printer" => "Invoice Printer", "invoice_printer" => "Invoice Printer",
"invoice_type" => "Invoice Type", "invoice_type" => "Invoice Type",
"is_readable" => "is readable, but the permissions are incorrectly set. Please set it to 640 or 660 and refresh.", "is_readable" => "is readable, but the permissions are incorrectly set. Please set it to 640 or 660 and refresh.",
"is_writable" => "is writable, but the permissions are incorrectly set. Please set it to 750 and refresh.", "is_writable" => "is writable, but the permissions are incorrectly set. Please set it to 750 and refresh.",
"item_markup" => "", "item_markup" => "",
"jsprintsetup_required" => "Warning: This functionality will only work if you have the FireFox jsPrintSetup addon installed. Save anyway?", "jsprintsetup_required" => "Warning: This functionality will only work if you have the FireFox jsPrintSetup addon installed. Save anyway?",
"language" => "Language", "language" => "Language",
"last_used_invoice_number" => "Last used Invoice Number", "last_used_invoice_number" => "Last used Invoice Number",
"last_used_quote_number" => "Last used Quote Number", "last_used_quote_number" => "Last used Quote Number",
"last_used_work_order_number" => "Last used W/O Number", "last_used_work_order_number" => "Last used W/O Number",
"left" => "Left", "left" => "Left",
"license" => "License", "license" => "License",
"license_configuration" => "License Statement", "license_configuration" => "License Statement",
"line_sequence" => "Line Sequence", "line_sequence" => "Line Sequence",
"lines_per_page" => "Lines per Page", "lines_per_page" => "Lines per Page",
"lines_per_page_number" => "Lines per Page must be a number.", "lines_per_page_number" => "Lines per Page must be a number.",
"lines_per_page_required" => "Lines per Page is a required field.", "lines_per_page_required" => "Lines per Page is a required field.",
"locale" => "Localization", "locale" => "Localization",
"locale_configuration" => "Localization Configuration", "locale_configuration" => "Localization Configuration",
"locale_info" => "Localization Configuration Information", "locale_info" => "Localization Configuration Information",
"location" => "Stock", "location" => "Stock",
"location_configuration" => "Stock Locations", "location_configuration" => "Stock Locations",
"location_info" => "Location Configuration Information", "location_info" => "Location Configuration Information",
"login_form" => "Login Form Style", "login_form" => "Login Form Style",
"logout" => "Do you want to make a backup before logging out? Click [OK] to backup or [Cancel] to logout.", "logout" => "Do you want to make a backup before logging out? Click [OK] to backup or [Cancel] to logout.",
"mailchimp" => "MailChimp", "mailchimp" => "MailChimp",
"mailchimp_api_key" => "MailChimp API Key", "mailchimp_api_key" => "MailChimp API Key",
"mailchimp_configuration" => "MailChimp Configuration", "mailchimp_configuration" => "MailChimp Configuration",
"mailchimp_key_successfully" => "API Key is valid.", "mailchimp_key_successfully" => "API Key is valid.",
"mailchimp_key_unsuccessfully" => "API Key is invalid.", "mailchimp_key_unsuccessfully" => "API Key is invalid.",
"mailchimp_lists" => "MailChimp List(s)", "mailchimp_lists" => "MailChimp List(s)",
"mailchimp_tooltip" => "Click the icon for an API Key.", "mailchimp_tooltip" => "Click the icon for an API Key.",
"message" => "Message", "message" => "Message",
"message_configuration" => "Message Configuration", "message_configuration" => "Message Configuration",
"msg_msg" => "Saved Text Message", "msg_msg" => "Saved Text Message",
"msg_msg_placeholder" => "If you wish to use a SMS template save your message here, otherwise leave the box blank.", "msg_msg_placeholder" => "If you wish to use a SMS template save your message here, otherwise leave the box blank.",
"msg_pwd" => "SMS-API Password", "msg_pwd" => "SMS-API Password",
"msg_pwd_required" => "SMS-API Password is a required field", "msg_pwd_required" => "SMS-API Password is a required field",
"msg_src" => "SMS-API Sender ID", "msg_src" => "SMS-API Sender ID",
"msg_src_required" => "SMS-API Sender ID is a required field", "msg_src_required" => "SMS-API Sender ID is a required field",
"msg_uid" => "SMS-API Username", "msg_uid" => "SMS-API Username",
"msg_uid_required" => "SMS-API Username is a required field", "msg_uid_required" => "SMS-API Username is a required field",
"multi_pack_enabled" => "Multiple Packages per Item", "multi_pack_enabled" => "Multiple Packages per Item",
"no_risk" => "No security/vulnerability risks.", "no_risk" => "No security/vulnerability risks.",
"none" => "none", "none" => "none",
"notify_alignment" => "Notification Popup Position", "notify_alignment" => "Notification Popup Position",
"number_format" => "Number Format", "number_format" => "Number Format",
"number_locale" => "Localization", "number_locale" => "Localization",
"number_locale_invalid" => "The entered locale is invalid. Check the link in the tooltip to find a valid locale.", "number_locale_invalid" => "The entered locale is invalid. Check the link in the tooltip to find a valid locale.",
"number_locale_required" => "Number Locale is a required field.", "number_locale_required" => "Number Locale is a required field.",
"number_locale_tooltip" => "Find a suitable locale through this link.", "number_locale_tooltip" => "Find a suitable locale through this link.",
"os_timezone" => "OSPOS Timezone:", "os_timezone" => "OSPOS Timezone:",
"ospos_info" => "OSPOS Installation Info", "ospos_info" => "OSPOS Installation Info",
"payment_options_order" => "Payment Options Order", "payment_options_order" => "Payment Options Order",
"perm_risk" => "Incorrect permissions leaves this software at risk.", "perm_risk" => "Incorrect permissions leaves this software at risk.",
"phone" => "Company Phone", "phone" => "Company Phone",
"phone_required" => "Company Phone is a required field.", "phone_required" => "Company Phone is a required field.",
"print_bottom_margin" => "Margin Bottom", "print_bottom_margin" => "Margin Bottom",
"print_bottom_margin_number" => "Margin Bottom must be a number.", "print_bottom_margin_number" => "Margin Bottom must be a number.",
"print_bottom_margin_required" => "Margin Bottom is a required field.", "print_bottom_margin_required" => "Margin Bottom is a required field.",
"print_delay_autoreturn" => "Autoreturn to Sale delay", "print_delay_autoreturn" => "Autoreturn to Sale delay",
"print_delay_autoreturn_number" => "Autoreturn to Sale delay is a required field.", "print_delay_autoreturn_number" => "Autoreturn to Sale delay is a required field.",
"print_delay_autoreturn_required" => "Autoreturn to Sale delay must be a number.", "print_delay_autoreturn_required" => "Autoreturn to Sale delay must be a number.",
"print_footer" => "Print Browser Footer", "print_footer" => "Print Browser Footer",
"print_header" => "Print Browser Header", "print_header" => "Print Browser Header",
"print_left_margin" => "Margin Left", "print_left_margin" => "Margin Left",
"print_left_margin_number" => "Margin Left must be a number.", "print_left_margin_number" => "Margin Left must be a number.",
"print_left_margin_required" => "Margin Left is a required field.", "print_left_margin_required" => "Margin Left is a required field.",
"print_receipt_check_behaviour" => "Print Receipt checkbox", "print_receipt_check_behaviour" => "Print Receipt checkbox",
"print_receipt_check_behaviour_always" => "Always checked", "print_receipt_check_behaviour_always" => "Always checked",
"print_receipt_check_behaviour_last" => "Remember last selection", "print_receipt_check_behaviour_last" => "Remember last selection",
"print_receipt_check_behaviour_never" => "Always unchecked", "print_receipt_check_behaviour_never" => "Always unchecked",
"print_right_margin" => "Margin Right", "print_right_margin" => "Margin Right",
"print_right_margin_number" => "Margin Right must be a number.", "print_right_margin_number" => "Margin Right must be a number.",
"print_right_margin_required" => "Margin Right is a required field.", "print_right_margin_required" => "Margin Right is a required field.",
"print_silently" => "Show Print Dialog", "print_silently" => "Show Print Dialog",
"print_top_margin" => "Margin Top", "print_top_margin" => "Margin Top",
"print_top_margin_number" => "Margin Top must be a number.", "print_top_margin_number" => "Margin Top must be a number.",
"print_top_margin_required" => "Margin Top is a required field.", "print_top_margin_required" => "Margin Top is a required field.",
"quantity_decimals" => "Quantity Decimals", "quantity_decimals" => "Quantity Decimals",
"quick_cash_enable" => "", "quick_cash_enable" => "",
"quote_default_comments" => "Default Quote Comments", "quote_default_comments" => "Default Quote Comments",
"receipt" => "Receipt", "receipt" => "Receipt",
"receipt_category" => "", "receipt_category" => "",
"receipt_configuration" => "Receipt Print Settings", "receipt_configuration" => "Receipt Print Settings",
"receipt_default" => "Default", "receipt_default" => "Default",
"receipt_font_size" => "Font Size", "receipt_font_size" => "Font Size",
"receipt_font_size_number" => "Font Size must be a number.", "receipt_font_size_number" => "Font Size must be a number.",
"receipt_font_size_required" => "Font Size is a required field.", "receipt_font_size_required" => "Font Size is a required field.",
"receipt_info" => "Receipt Configuration Information", "receipt_info" => "Receipt Configuration Information",
"receipt_printer" => "Ticket Printer", "receipt_printer" => "Ticket Printer",
"receipt_short" => "Short", "receipt_short" => "Short",
"receipt_show_company_name" => "Show Company Name", "receipt_show_company_name" => "Show Company Name",
"receipt_show_description" => "Show Description", "receipt_show_description" => "Show Description",
"receipt_show_serialnumber" => "Show Serial Number", "receipt_show_serialnumber" => "Show Serial Number",
"receipt_show_secondary_currency" => "Show Secondary Currency",
"receipt_show_tax_ind" => "Show Tax Indicator", "receipt_show_tax_ind" => "Show Tax Indicator",
"receipt_show_taxes" => "Show Taxes", "receipt_show_taxes" => "Show Taxes",
"receipt_show_total_discount" => "Show Total Discount", "receipt_show_total_discount" => "Show Total Discount",
"receipt_template" => "Receipt Template", "receipt_template" => "Receipt Template",
"secondary_currency" => "Secondary Currency",
"secondary_currency_decimals" => "Secondary Currency Decimals",
"secondary_currency_code" => "Secondary Currency Code",
"secondary_currency_enable" => "Enable Secondary Currency",
"secondary_currency_enable_tooltip" => "Show secondary currency fields and print/display values across the app.",
"secondary_currency_rate" => "Secondary Currency Rate",
"secondary_currency_settings" => "Secondary Currency Settings",
"secondary_currency_symbol" => "Secondary Currency Symbol",
"receiving_calculate_average_price" => "Calc avg. Price (Receiving)", "receiving_calculate_average_price" => "Calc avg. Price (Receiving)",
"recv_invoice_format" => "Receivings Invoice Format", "recv_invoice_format" => "Receivings Invoice Format",
"register_mode_default" => "Default Register Mode", "register_mode_default" => "Default Register Mode",
"report_an_issue" => "Report an issue", "report_an_issue" => "Report an issue",
"return_policy_required" => "Return policy is a required field.", "return_policy_required" => "Return policy is a required field.",
"reward" => "Reward", "reward" => "Reward",
"reward_configuration" => "Reward Configuration", "reward_configuration" => "Reward Configuration",
"right" => "Right", "right" => "Right",
"sales_invoice_format" => "Sales Invoice Format", "sales_invoice_format" => "Sales Invoice Format",
"sales_quote_format" => "Sales Quote Format", "sales_quote_format" => "Sales Quote Format",
"mailpath_invalid" => "Invalid sendmail path. Only letters, numbers, dashes, underscores, slashes and dots are allowed.", "mailpath_invalid" => "Invalid sendmail path. Only letters, numbers, dashes, underscores, slashes and dots are allowed.",
"saved_successfully" => "Configuration save successful.", "saved_successfully" => "Configuration save successful.",
"saved_unsuccessfully" => "Configuration save failed.", "saved_unsuccessfully" => "Configuration save failed.",
"security_issue" => "Security Vulnerability Warning", "security_issue" => "Security Vulnerability Warning",
"server_notice" => "Please use the below info for issue reporting.", "server_notice" => "Please use the below info for issue reporting.",
"service_charge" => "", "service_charge" => "",
"customer_display" => "Customer Display",
"show_due_enable" => "", "show_due_enable" => "",
"show_office_group" => "Show office icon", "show_office_group" => "Show office icon",
"statistics" => "Send Statistics", "statistics" => "Send Statistics",
"statistics_tooltip" => "Send statistics for development and feature improvement purposes.", "statistics_tooltip" => "Send statistics for development and feature improvement purposes.",
"stock_location" => "Stock location", "stock_location" => "Stock location",
"stock_location_duplicate" => "Stock Location must be unique.", "stock_location_duplicate" => "Stock Location must be unique.",
"stock_location_invalid_chars" => "Stock Location can not contain '_'.", "stock_location_invalid_chars" => "Stock Location can not contain '_'.",
"stock_location_required" => "Stock location is a required field.", "stock_location_required" => "Stock location is a required field.",
"suggestions_fifth_column" => "", "suggestions_fifth_column" => "",
"suggestions_first_column" => "Column 1", "suggestions_first_column" => "Column 1",
"suggestions_fourth_column" => "", "suggestions_fourth_column" => "",
"suggestions_layout" => "Search Suggestions Layout", "suggestions_layout" => "Search Suggestions Layout",
"suggestions_second_column" => "Column 2", "suggestions_second_column" => "Column 2",
"suggestions_third_column" => "Column 3", "suggestions_third_column" => "Column 3",
"system_conf" => "Setup & Conf", "shortcuts" => "Shortcuts",
"system_info" => "System Info", "shortcuts_configuration" => "Sales Keyboard Shortcut Configuration",
"table" => "Table", "shortcuts_duplicate_bindings" => "Shortcut bindings must be unique.",
"table_configuration" => "Table Configuration", "shortcuts_save_error" => "Unable to save shortcut settings.",
"takings_printer" => "Receipt Printer", "system_conf" => "Setup & Conf",
"tax" => "Tax", "system_info" => "System Info",
"tax_category" => "Tax Category", "table" => "Table",
"tax_category_duplicate" => "The entered tax category already exists.", "table_configuration" => "Table Configuration",
"tax_category_invalid_chars" => "The entered tax category is invalid.", "takings_printer" => "Receipt Printer",
"tax_category_required" => "The tax category is required.", "tax" => "Tax",
"tax_category_used" => "Tax category cannot be deleted because it is being used.", "tax_category" => "Tax Category",
"tax_configuration" => "Tax Configuration", "tax_category_duplicate" => "The entered tax category already exists.",
"tax_decimals" => "Tax Decimals", "tax_category_invalid_chars" => "The entered tax category is invalid.",
"tax_id" => "Tax Id", "tax_category_required" => "The tax category is required.",
"tax_included" => "Tax Included", "tax_category_used" => "Tax category cannot be deleted because it is being used.",
"theme" => "Theme", "tax_configuration" => "Tax Configuration",
"theme_preview" => "Preview Theme:", "tax_decimals" => "Tax Decimals",
"thousands_separator" => "Thousands Separator", "tax_id" => "Tax Id",
"timezone" => "Timezone", "tax_included" => "Tax Included",
"timezone_error" => "OSPOS Timezone is Different from your Local Timezone.", "theme" => "Theme",
"top" => "Top", "theme_preview" => "Preview Theme:",
"use_destination_based_tax" => "Use Destination Based Tax", "thousands_separator" => "Thousands Separator",
"user_timezone" => "Local Timezone:", "timezone" => "Timezone",
"website" => "Website", "timezone_error" => "OSPOS Timezone is Different from your Local Timezone.",
"wholesale_markup" => "", "top" => "Top",
"work_order_enable" => "Work Order Support", "use_destination_based_tax" => "Use Destination Based Tax",
"work_order_format" => "Work Order Format", "user_timezone" => "Local Timezone:",
]; "website" => "Website",
"wholesale_markup" => "",
"work_order_enable" => "Work Order Support",
"work_order_format" => "Work Order Format",
];

View File

@@ -7,7 +7,6 @@ return [
"account_number" => "Account #", "account_number" => "Account #",
"add_payment" => "Add Payment", "add_payment" => "Add Payment",
"amount_due" => "Amount Due", "amount_due" => "Amount Due",
"amount_due_lbp" => "Amount Due LBP",
"amount_tendered" => "Amount Tendered", "amount_tendered" => "Amount Tendered",
"authorized_signature" => "Authorized Signature", "authorized_signature" => "Authorized Signature",
"cancel_sale" => "Cancel", "cancel_sale" => "Cancel",
@@ -20,8 +19,6 @@ return [
"cash_deposit" => "Cash Deposit", "cash_deposit" => "Cash Deposit",
"cash_filter" => "Cash", "cash_filter" => "Cash",
"change_due" => "Change Due", "change_due" => "Change Due",
"change" => "Change",
"currency_rate" => "Currency Rate",
"change_price" => "Change Selling Price", "change_price" => "Change Selling Price",
"check" => "Check", "check" => "Check",
"check_balance" => "Check remainder", "check_balance" => "Check remainder",
@@ -43,7 +40,6 @@ return [
"customer_address" => "Address", "customer_address" => "Address",
"customer_discount" => "Discount", "customer_discount" => "Discount",
"customer_email" => "Email", "customer_email" => "Email",
"customer_name" => "Customer Name",
"customer_location" => "Location", "customer_location" => "Location",
"customer_mailchimp_status" => "MailChimp Status", "customer_mailchimp_status" => "MailChimp Status",
"customer_optional" => "(Required for Due Payments)", "customer_optional" => "(Required for Due Payments)",
@@ -77,6 +73,12 @@ return [
"employee" => "Employee", "employee" => "Employee",
"entry" => "Entry", "entry" => "Entry",
"error_editing_item" => "Error editing item", "error_editing_item" => "Error editing item",
"negative_price_invalid" => "Price cannot be negative.",
"negative_quantity_invalid" => "Quantity cannot be negative.",
"negative_discount_invalid" => "Discount cannot be negative.",
"discount_percent_exceeds_100" => "Percentage discount cannot exceed 100%.",
"discount_exceeds_item_total" => "Discount cannot exceed the item total.",
"negative_total_invalid" => "Sale total cannot be negative. Check item discounts and quantities.",
"find_or_scan_item" => "Find or Scan Item", "find_or_scan_item" => "Find or Scan Item",
"find_or_scan_item_or_receipt" => "Find or Scan Item or Receipt", "find_or_scan_item_or_receipt" => "Find or Scan Item or Receipt",
"giftcard" => "Gift Card", "giftcard" => "Gift Card",
@@ -107,7 +109,6 @@ return [
"item_name" => "Item Name", "item_name" => "Item Name",
"item_number" => "Item #", "item_number" => "Item #",
"item_out_of_stock" => "Item is out of stock.", "item_out_of_stock" => "Item is out of stock.",
"items" => "Items",
"key_browser" => "Helpful Shortcuts", "key_browser" => "Helpful Shortcuts",
"key_cancel" => "Cancels Current Quote/Invoice/Sale", "key_cancel" => "Cancels Current Quote/Invoice/Sale",
"key_customer_search" => "Customer Search", "key_customer_search" => "Customer Search",
@@ -149,9 +150,7 @@ return [
"payment_type" => "Type", "payment_type" => "Type",
"payments" => "", "payments" => "",
"payments_total" => "Payments Total", "payments_total" => "Payments Total",
"loyalty_reward_points" => "Loyalty Reward Points",
"price" => "Price", "price" => "Price",
"price_with_currency" => "Price (%s)",
"print_after_sale" => "Print after Sale", "print_after_sale" => "Print after Sale",
"quantity" => "Quantity", "quantity" => "Quantity",
"quantity_less_than_reorder_level" => "Warning: Desired Quantity is below Reorder Level for that Item.", "quantity_less_than_reorder_level" => "Warning: Desired Quantity is below Reorder Level for that Item.",
@@ -167,13 +166,10 @@ return [
"receipt_number" => "Sale #", "receipt_number" => "Sale #",
"receipt_sent" => "Receipt sent to", "receipt_sent" => "Receipt sent to",
"receipt_unsent" => "Receipt failed to be sent to", "receipt_unsent" => "Receipt failed to be sent to",
"rate" => "Rate",
"refund" => "Refund Type", "refund" => "Refund Type",
"register" => "Sales Register", "register" => "Sales Register",
"remove_customer" => "Remove Customer", "remove_customer" => "Remove Customer",
"remove_discount" => "", "remove_discount" => "",
"customer_display" => "Customer Display",
"summary" => "Summary",
"return" => "Return", "return" => "Return",
"rewards" => "Reward Points", "rewards" => "Reward Points",
"rewards_balance" => "Reward Points Balance", "rewards_balance" => "Reward Points Balance",
@@ -185,7 +181,6 @@ return [
"sales_total" => "", "sales_total" => "",
"select_customer" => "Select Customer", "select_customer" => "Select Customer",
"selected_customer" => "Selected Customer", "selected_customer" => "Selected Customer",
"walk_in_customer" => "Walk-in Customer",
"send_invoice" => "Send Invoice", "send_invoice" => "Send Invoice",
"send_quote" => "Send Quote", "send_quote" => "Send Quote",
"send_receipt" => "Send Receipt", "send_receipt" => "Send Receipt",
@@ -216,7 +211,6 @@ return [
"tax_percent" => "Tax %", "tax_percent" => "Tax %",
"taxed_ind" => "T", "taxed_ind" => "T",
"total" => "Total", "total" => "Total",
"total_lbp" => "Total LBP",
"total_tax_exclusive" => "Tax excluded", "total_tax_exclusive" => "Tax excluded",
"transaction_failed" => "Sales Transaction failed.", "transaction_failed" => "Sales Transaction failed.",
"unable_to_add_item" => "Item add to Sale failed", "unable_to_add_item" => "Item add to Sale failed",
@@ -236,5 +230,3 @@ return [
"work_order_sent" => "Work Order sent to", "work_order_sent" => "Work Order sent to",
"work_order_unsent" => "Work Order failed to be sent to", "work_order_unsent" => "Work Order failed to be sent to",
]; ];

View File

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

View File

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

View File

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

View File

@@ -108,11 +108,6 @@ class Sale_lib
'custom_tax_invoice' 'custom_tax_invoice'
]; ];
private const ALLOWED_RECEIPT_TEMPLATES = [
'receipt_default',
'receipt_short'
];
public function get_invoice_type_options(): array public function get_invoice_type_options(): array
{ {
$invoice_types = []; $invoice_types = [];
@@ -166,11 +161,6 @@ class Sale_lib
return in_array($invoice_type, self::ALLOWED_INVOICE_TYPES, true); 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 * @return array
*/ */

View File

@@ -601,10 +601,6 @@ class Attribute extends Model
*/ */
public function saveAttributeLink(int $itemId, int $definitionId, int $attributeId): bool public function saveAttributeLink(int $itemId, int $definitionId, int $attributeId): bool
{ {
if ($attributeId <= 0) {
return false;
}
$normalizedItemId = empty($itemId) ? null : $itemId; $normalizedItemId = empty($itemId) ? null : $itemId;
$normalizedAttributeId = empty($attributeId) ? null : $attributeId; $normalizedAttributeId = empty($attributeId) ? null : $attributeId;

View File

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

View File

@@ -277,14 +277,6 @@ class Sale extends Model
$builder->like('payment_type', lang('Sales.debit')); $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'); $builder->groupBy('payment_type');
$payments = $builder->get()->getResultArray(); $payments = $builder->get()->getResultArray();
@@ -1517,13 +1509,5 @@ class Sale extends Model
if ($filters['only_check']) { if ($filters['only_check']) {
$builder->like('payments.payment_type', lang('Sales.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

File diff suppressed because it is too large Load Diff

View File

@@ -1,249 +0,0 @@
<?php
/**
* @var array $config
* @var string $companyName
* @var string $companyDetails
*/
helper('url');
?>
<!doctype html>
<html lang="<?= esc(service('request')->getLocale()) ?>">
<head>
<meta charset="utf-8">
<title><?= lang('Sales.customer_display') ?></title>
<link rel="shortcut icon" type="image/x-icon" href="<?= base_url('images/favicon.ico') ?>">
<link rel="stylesheet" href="<?= base_url('resources/bootswatch/' . (empty($config['theme']) ? 'flatly' : esc($config['theme'])) . '/bootstrap.min.css') ?>">
<link rel="stylesheet" href="<?= base_url('resources/opensourcepos-8e34d6a398.min.css') ?>">
<style>
html, body {
margin: 0;
padding: 0;
background: #f8f8f8;
color: #333;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
body {
width: 100%;
overflow: hidden;
}
.customer-display-header {
background: #1f3143;
color: #fff;
text-align: center;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
padding: 6px 12px;
border-bottom: 1px solid #102131;
}
.customer-display-shell {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 12px 18px 18px;
box-sizing: border-box;
}
.customer-display-company {
text-align: center;
margin-bottom: 18px;
}
.customer-display-company img {
display: block;
margin: 0 auto 6px;
max-height: 84px;
max-width: 240px;
}
.customer-display-company .company-name {
font-size: 20px;
font-weight: 600;
line-height: 1.2;
margin-top: 12px;
}
.customer-display-company .company-details {
font-size: 13px;
line-height: 1.35;
white-space: pre-line;
}
.customer-display-company .company-phone {
font-size: 13px;
line-height: 1.35;
margin-top: 4px;
}
.customer-display-main-row {
display: flex;
gap: 14px;
align-items: flex-start;
margin-top: 6px;
}
.customer-display-cart-column {
flex: 1 1 auto;
min-width: 0;
}
.customer-display-summary-column {
flex: 0 0 320px;
width: 320px;
}
.customer-display-summary-panel,
.customer-display-info-panel,
.customer-display-items-panel {
margin-bottom: 0;
}
.customer-display-summary-panel .panel-heading,
.customer-display-info-panel .panel-heading,
.customer-display-items-panel .panel-heading {
font-weight: 600;
}
.customer-display-summary-panel .table,
.customer-display-info-table {
margin-bottom: 0;
font-size: 13px;
}
.customer-display-summary-panel .table > tbody > tr > th,
.customer-display-info-table > tbody > tr > th {
background: #f8fbfd;
width: 56%;
font-weight: 700;
}
.customer-display-summary-panel .table > tbody > tr > td,
.customer-display-info-table > tbody > tr > td {
width: 44%;
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.customer-display-summary-panel .rate-row th,
.customer-display-summary-panel .rate-row td {
color: #c00000;
}
.customer-display-summary-panel .summary-section-row th {
background: #eaf2f8;
color: #1f3b5b;
font-weight: 700;
}
.customer-display-summary-panel .summary-subtable {
width: 100%;
}
.customer-display-summary-panel .summary-subtable > tbody > tr > th {
background: #fdfefe;
font-weight: 600;
}
.customer-display-summary-panel .summary-subtable > tbody > tr > td {
font-weight: 600;
}
.register-wrap {
width: 100%;
}
#register {
width: 100%;
margin: 0;
table-layout: fixed;
background: #fff;
}
#register th,
#register td {
text-align: center;
vertical-align: middle;
padding: 6px 5px;
word-wrap: break-word;
}
#register thead th {
font-size: 12px;
font-weight: 600;
color: #333;
}
#register tbody td {
font-size: 15px;
}
#register tbody td.item-name-cell {
font-size: 16px;
text-align: left;
}
#register tbody td.price-cell {
font-size: 15px;
}
#register tbody td.serial-cell {
font-size: 12px;
color: #2F4F4F;
}
.customer-display-summary-panel .table > tbody > tr > th,
.customer-display-info-table > tbody > tr > th {
border-top: 1px solid #e5e5e5;
}
.customer-display-summary-panel .table > tbody > tr > td,
.customer-display-info-table > tbody > tr > td {
border-top: 1px solid #e5e5e5;
}
.customer-display-summary-panel .panel-body,
.customer-display-info-panel .panel-body,
.customer-display-items-panel .panel-body {
padding: 12px 15px;
}
.customer-display-summary-column .panel-body {
padding-top: 8px;
}
.customer-display-summary-column .customer-name-value,
.customer-display-summary-column .giftcard-value,
.customer-display-summary-column .reward-value {
text-align: right;
}
.customer-display-footer {
margin-top: 14px;
text-align: center;
font-size: 12px;
color: #777;
}
</style>
</head>
<body>
<div class="customer-display-header">Open Source Point of Sale</div>
<div class="customer-display-shell">
<div class="customer-display-company">
<?php if (!empty($config['company_logo'])) { ?>
<img src="<?= base_url('uploads/' . esc($config['company_logo'], 'url')) ?>" alt="company_logo">
<?php } ?>
<div class="company-name"><?= esc($companyName) ?></div>
<div class="company-phone">Phone: <?= esc((string)($config['phone'] ?? '')) ?></div>
<?php if ($companyDetails !== '') { ?>
<div class="company-details"><?= nl2br(esc($companyDetails)) ?></div>
<?php } ?>
</div>
<div class="customer-display-main-row">

View File

@@ -1,224 +0,0 @@
<?php
/**
* @var array $cart
* @var array $config
* @var float $rate
* @var float $total
* @var float $subtotal
* @var float $prediscount_subtotal
* @var array $taxes
* @var array $payments
* @var float $amount_change
*/
$priceWithCurrencyLabel = lang('Sales.price_with_currency');
?>
<?= view('partial/customer_display_header') ?>
<div class="customer-display-cart-column">
<div class="register-wrap">
<div class="panel panel-default customer-display-items-panel">
<div class="panel-heading"><?= lang('Sales.items') ?></div>
<div class="panel-body table-responsive">
<table class="table table-striped table-condensed" id="register">
<thead>
<tr>
<th style="width: <?= (int) $cartItemWidth ?>%;"><?= lang('Sales.item_name') ?></th>
<?php if ($cartHasCustomerDisplay) { ?>
<th style="width: <?= (int) $cartPriceWidth ?>%;"><?= sprintf($priceWithCurrencyLabel, esc($customerDisplayCurrencyLabel)) ?></th>
<?php } ?>
<th style="width: <?= (int) $cartOriginalWidth ?>%;"><?= sprintf($priceWithCurrencyLabel, esc($originalCurrencyLabel)) ?></th>
<th style="width: <?= (int) $cartQuantityWidth ?>%;"><?= lang('Sales.quantity') ?></th>
<th style="width: <?= (int) $cartDiscountWidth ?>%;"><?= lang('Sales.discount') ?></th>
<th style="width: <?= (int) $cartTotalWidth ?>%;"><?= lang('Sales.total') ?></th>
</tr>
</thead>
<tbody id="cart_contents">
<?php if (count($cart) == 0) { ?>
<tr>
<td colspan="<?= (int) $cartColspan ?>">
<div class="alert alert-dismissible alert-info"><?= lang('Sales.no_items_in_cart') ?></div>
</td>
</tr>
<?php } else { ?>
<?php foreach (array_reverse($cart, true) as $line => $item) { ?>
<tr>
<td class="item-name-cell">
<?= esc($item['name']) ?><br>
<?= !empty($item['attribute_values']) ? esc($item['attribute_values']) : '' ?>
</td>
<?php if ($cartHasCustomerDisplay) { ?>
<td class="price-cell">
<?= to_secondary_currency((float)$item['price'], $secondaryCurrency) ?>
</td>
<?php } ?>
<td class="price-cell">
<?= to_currency($item['price']) ?>
</td>
<td class="price-cell">
<?= to_quantity_decimals($item['quantity']) ?>
</td>
<td class="price-cell">
<?= to_decimals($item['discount'], 0) ?>
</td>
<td class="price-cell">
<?= $item['item_type'] == ITEM_AMOUNT_ENTRY ? to_currency_no_money($item['discounted_total']) : to_currency($item['discounted_total']) ?>
</td>
</tr>
<tr>
<td colspan="<?= $cartHasCustomerDisplay ? 3 : 2 ?>"></td>
<td class="serial-cell">
<?= $item['is_serialized'] == 1 ? lang('Sales.serial') : '' ?>
</td>
<td colspan="2" class="serial-cell">
<?php if ($item['is_serialized'] == 1) {
echo esc($item['serialnumber']);
} ?>
</td>
</tr>
<?php } ?>
<?php } ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="customer-display-summary-column">
<div class="panel panel-primary customer-display-summary-panel">
<div class="panel-heading"><?= lang('Sales.summary') ?></div>
<div class="panel-body">
<table class="table table-condensed summary-subtable">
<tbody>
<tr>
<th><?= lang('Sales.total') ?></th>
<td><?= to_currency($total) ?></td>
</tr>
<?php if ($showCustomerDisplay): ?>
<tr>
<th><?= lang('Sales.total') ?> <?= esc($customerDisplayCurrencyLabel) ?></th>
<td><?= to_secondary_currency((float)$total, $secondaryCurrency) ?></td>
</tr>
<tr class="rate-row">
<th><?= lang('Sales.rate') ?></th>
<td><?= number_format((float) $rate, 2) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
<table class="table table-condensed summary-subtable" style="margin-top: 10px;">
<tbody>
<tr class="summary-section-row">
<th colspan="2"><?= lang('Sales.customer') ?></th>
</tr>
<tr>
<th><?= lang('Sales.customer_name') ?></th>
<td class="customer-name-value"><?= esc($customerName ?? lang('Sales.walk_in_customer')) ?></td>
</tr>
<tr>
<th><?= lang('Sales.giftcard_balance') ?></th>
<td class="giftcard-value"><?= to_currency((float) ($giftcardRemainder ?? 0)) ?></td>
</tr>
<tr>
<th><?= lang('Sales.loyalty_reward_points') ?></th>
<td class="reward-value"><?= esc((string)($customerRewardPoints ?? 0)) ?></td>
</tr>
</tbody>
</table>
<table class="table table-condensed summary-subtable" style="margin-top: 10px;">
<tbody>
<tr class="summary-section-row">
<th colspan="2"><?= lang('Sales.change') ?></th>
</tr>
<tr>
<th><?= lang('Sales.payments_total') ?></th>
<td><?= to_currency($payments_total) ?></td>
</tr>
<tr>
<th><?= lang('Sales.amount_due') ?></th>
<td><?= to_currency($amount_due) ?></td>
</tr>
<?php if ($showCustomerDisplay): ?>
<tr>
<th><?= lang('Sales.amount_due') ?> <?= esc($customerDisplayCurrencyLabel) ?></th>
<td><?= to_secondary_currency((float)$amount_due, $secondaryCurrency) ?></td>
</tr>
<?php endif; ?>
<tr>
<th><?= lang('Sales.change_due') ?></th>
<td><?= to_currency($paymentChangeDue ?? 0) ?></td>
</tr>
<?php if ($showCustomerDisplay): ?>
<tr>
<th><?= lang('Sales.change_due') ?> <?= esc($customerDisplayCurrencyLabel) ?></th>
<td><?= to_secondary_currency((float)($paymentChangeDue ?? 0), $secondaryCurrency) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="customer-display-footer"></div>
</div>
<script>
const customerDisplayId = new URLSearchParams(window.location.search).get('displayId') || '';
const customerDisplayStorageSuffix = customerDisplayId !== '' ? '_' + customerDisplayId : '';
const customerDisplayStorageKeys = {
open: 'customerDisplayOpen' + customerDisplayStorageSuffix,
dirtyAt: 'customerDisplayDirtyAt' + customerDisplayStorageSuffix
};
localStorage.setItem(customerDisplayStorageKeys.open, '1');
let lastDirtyAt = localStorage.getItem(customerDisplayStorageKeys.dirtyAt) || '';
let refreshTimer = null;
const scheduleRefresh = function(dirtyAt) {
if (refreshTimer !== null) {
clearTimeout(refreshTimer);
}
refreshTimer = setTimeout(function() {
if (localStorage.getItem(customerDisplayStorageKeys.open) !== '1') {
return;
}
if (localStorage.getItem(customerDisplayStorageKeys.dirtyAt) === dirtyAt) {
window.location.reload();
}
}, 700);
};
const checkForRefresh = function() {
const dirtyAt = localStorage.getItem(customerDisplayStorageKeys.dirtyAt) || '';
if (dirtyAt !== '' && dirtyAt !== lastDirtyAt) {
lastDirtyAt = dirtyAt;
scheduleRefresh(dirtyAt);
}
};
window.addEventListener('storage', function(event) {
if (event.key === customerDisplayStorageKeys.dirtyAt) {
checkForRefresh();
}
});
setInterval(checkForRefresh, 500);
window.addEventListener('beforeunload', function() {
localStorage.removeItem(customerDisplayStorageKeys.open);
});
</script>
</body>
</html>

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ class AppTest extends CIUnitTestCase
// Clean up environment // Clean up environment
putenv('CI_ENVIRONMENT'); putenv('CI_ENVIRONMENT');
putenv('app.allowedHostnames'); putenv('app.allowedHostnames');
putenv('ALLOWED_HOSTNAMES');
unset($_SERVER['HTTP_HOST']); unset($_SERVER['HTTP_HOST']);
} }
@@ -282,106 +281,4 @@ class AppTest extends CIUnitTestCase
putenv('app.allowedHostnames'); putenv('app.allowedHostnames');
putenv('CI_ENVIRONMENT'); 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');
}
} }