Compare commits

..

30 Commits

Author SHA1 Message Date
WebShells
1a6cfffc27 Fix locale parsing 2026-05-20 23:37:54 +03:00
WebShells
5e0541c53e Restore secondary display sales flow 2026-05-19 21:33:54 +03:00
objecttothis
b0dddc22a3 Bugfixes to get Migration working on MySQL and MariaDB (#4551)
* Bugfixes to get Migration working on MySQL

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

* MariaDB compatibility fixes

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

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

* Fix changes which break MySQL migrations

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

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

* Resolve code review recommendations

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

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

* Refactor out duplicate code

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

* Initialize array variable causing potential issues

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

---------

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

Add GitHub Actions workflows for controlled deployments:

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

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

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

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

* refactor: Limit deployment to staging only

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

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

Restructure workflows to eliminate code duplication:

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

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

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

---------

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

Fixes #4460

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

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

Fixes #4475

* fix: Change isset to !empty for items_taxes_data check

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

Address CodeRabbit review feedback

* fix: Capture inventory insert result in save_inventory_quantities

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

Address CodeRabbit review feedback

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

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

* Fix sales shortcut payment flow

* Resolve shortcut keys review comment

* Sanitize shortcut config notifications

* Clarify keyboard shortcut configuration labels

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #4517

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

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

Closes #4514

* refactor: Extract img_tag building into helper method

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

* refactor: Move logo-related methods to Email_lib

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

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

* fix: Address CodeRabbit review comments

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

---------

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

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

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

Fixes #4494

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

Closes #4487

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

Fixes #4461

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-16 14:14:29 +04:00
83 changed files with 8027 additions and 5356 deletions

View File

@@ -16,6 +16,9 @@ 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,6 +123,7 @@ jobs:
. .
!.git !.git
!node_modules !node_modules
include-hidden-files: true
retention-days: 1 retention-days: 1
docker: docker:

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -13,7 +13,8 @@ RUN echo "date.timezone = \"\${PHP_TIMEZONE}\"" > /usr/local/etc/php/conf.d/time
WORKDIR /app WORKDIR /app
COPY --chown=www-data:www-data . /app COPY --chown=www-data:www-data . /app
RUN chmod 770 /app/writable/uploads /app/writable/logs /app/writable/cache \ RUN chmod 750 /app/writable/logs /app/writable/uploads /app/writable/cache /app/public/uploads /app/public/uploads/item_pics \
&& chmod 640 /app/writable/uploads/importCustomers.csv \
&& ln -s /app/*[^public] /var/www \ && 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,8 +5,9 @@
- [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 auto update --> <!-- END doctoc generated TOC please keep comment here to allow update -->
# Security Policy # Security Policy
@@ -21,26 +22,116 @@ We release patches for security vulnerabilities.
## Security Advisories ## Security Advisories
The following security vulnerabilities have been published: 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).
### 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
Please report (suspected) security vulnerabilities to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**. **Option 1: GitHub Security Advisory (Preferred)**
You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days. 1. Create a draft security advisory directly on GitHub:
- Go to https://github.com/opensourcepos/opensourcepos/security/advisories
- Click "New draft security advisory"
- Fill in the vulnerability details using our [template below](#vulnerability-template)
- Submit as **draft** (not published)
2. Notify us for triage:
- Send an email to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)** with:
- Subject: `[GHSA] Brief description of vulnerability`
- Link to the draft advisory
- Brief summary
**Option 2: Email Report**
Send vulnerability details to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**.
You will receive a response within 48 hours. Confirmed vulnerabilities will be patched within a few days depending on complexity.
## Disclosure Process
### Timeline
| Step | Timeline | Action |
|------|----------|--------|
| 1. Report received | Day 0 | We acknowledge within 48 hours |
| 2. Triage & confirmation | Day 1-3 | We validate the vulnerability |
| 3. Fix development | Day 3-7 | We develop and test the fix |
| 4. Patch release | Day 7-10 | We release a security patch |
| 5. CVE request | Day 7-14 | We request CVE from GitHub (if applicable) |
| 6. Advisory published | Day 14 | We publish the advisory with credit |
| 7. Public disclosure | Day 14+ | Full disclosure after patch release |
### CVE Process
**We request CVE identifiers through GitHub's security advisory system.** This is the preferred and easiest method:
1. After we confirm and fix the vulnerability, we'll request a CVE through GitHub
2. GitHub coordinates with MITRE on our behalf
3. The CVE is automatically linked to the advisory
4. You'll be credited as the reporter in the published advisory
**Already have a CVE?** If you've already obtained a CVE from another source (e.g., VulDB, CVE.MITRE.ORG), please include it in your report or advisory. We'll update our advisory to reference the existing CVE.
### No Bug Bounty Program
**Important:** Open Source Point of Sale does not offer a bug bounty program.
- All security research and vulnerability triage is done on a **voluntary basis** in our free time
- We do not offer monetary rewards for vulnerability reports
- We do credit reporters in published advisories (unless anonymity is requested)
- We greatly appreciate the security research community's efforts to help improve project security
### Security Best Practices for Researchers
- **Do not** access, modify, or delete data that doesn't belong to you
- **Do not** perform denial of service attacks
- **Do not** publicly disclose vulnerabilities before we've had time to fix them
- **Do** provide sufficient information to reproduce the vulnerability
- **Do** allow us reasonable time to fix before public disclosure
- **Do** report through official channels (GitHub advisories or email)
### Vulnerability Template
When creating a draft advisory, please include:
```
## Summary
[Brief description of the vulnerability]
## Impact
- **Confidentiality:** [High/Medium/Low - what data can be exposed]
- **Integrity:** [High/Medium/Low - what can be modified]
- **Availability:** [High/Medium/Low - service disruption potential]
- **Privilege Required:** [None/Low/High - authentication level needed]
- **CVSS v3.1:** [Score] ([Vector string])
## Details
[Technical details about the vulnerability]
**Affected Code:**
```php
// Path to affected file and vulnerable code
```
**Attack Vector:**
[How an attacker can exploit this]
## Proof of Concept
```bash
# Steps to reproduce
```
## Patch
[Suggested fix or approach]
## Affected Versions
- OpenSourcePOS X.Y.Z and earlier
## Credit
[Your GitHub username or preferred name]
```
---
**Thank you to all security researchers who have contributed to making Open Source Point of Sale more secure.** Your voluntary efforts help protect thousands of users worldwide and contribute to a safer, more trustworthy free and open-source software ecosystem. We deeply appreciate your responsible disclosure and the time you invest in improving our project.
If you've reported a vulnerability and would like to discuss CVE coordination or have questions about the process, please reach out to us at [jeroen@steganos.dev](mailto:jeroen@steganos.dev).

View File

@@ -58,9 +58,9 @@ class App extends BaseConfig
* Allowed Hostnames in the Site URL other than the hostname in the baseURL. * 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.
* *
* E.g., * Or via environment variable (useful for Docker/Compose):
* When your site URL ($baseURL) is 'http://example.com/', and your site * ALLOWED_HOSTNAMES=example.com,www.example.com
* 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,7 +286,11 @@ 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
$envAllowedHostnames = getenv('app.allowedHostnames'); // Support both: app.allowedHostnames (from .env) and ALLOWED_HOSTNAMES (from environment/Docker)
$envAllowedHostnames = getenv('ALLOWED_HOSTNAMES');
if ($envAllowedHostnames === false || trim($envAllowedHostnames) === '') {
$envAllowedHostnames = getenv('app.allowedHostnames');
}
if ($envAllowedHostnames !== false && trim($envAllowedHostnames) !== '') { 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)),
@@ -327,7 +331,7 @@ 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. ' . 'Set app.allowedHostnames in your .env file or ALLOWED_HOSTNAMES environment variable. ' .
'Example: app.allowedHostnames = "example.com,www.example.com" ' . 'Example: app.allowedHostnames = "example.com,www.example.com" ' .
'Received Host: ' . $httpHost; 'Received Host: ' . $httpHost;

View File

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

View File

@@ -5,7 +5,7 @@ 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 CodeIgniter\Database\Exceptions\DatabaseException; 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 +14,7 @@ use CodeIgniter\Database\Exceptions\DatabaseException;
*/ */
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,23 +34,35 @@ class OSPOS extends BaseConfig
if ($cache) { if ($cache) {
$this->settings = decode_array($cache); $this->settings = decode_array($cache);
} else { return;
try {
$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 (DatabaseException $e) {
// Database table doesn't exist yet (migrations haven't run)
// Return empty settings to allow migration page to display
$this->settings = [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home'
];
}
} }
try {
$db = Database::connect();
if (!$db->tableExists('app_config')) {
$this->settings = $this->getDefaultSettings();
return;
}
$appconfig = model(Appconfig::class);
foreach ($appconfig->get_all()->getResult() as $app_config) {
$this->settings[$app_config->key] = $app_config->value;
}
$this->cache->save('settings', encode_array($this->settings));
} catch (\Exception $e) {
$this->settings = $this->getDefaultSettings();
}
}
private function getDefaultSettings(): array
{
return [
'language' => 'english',
'language_code' => 'en',
'company' => 'Home',
'barcode_type' => 'Code39'
];
} }
/** /**
@@ -61,4 +73,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,6 +12,40 @@ $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');
@@ -39,4 +73,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

@@ -3,7 +3,6 @@
namespace Config; namespace Config;
use CodeIgniter\Config\BaseConfig; use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Session\Handlers\BaseHandler; use CodeIgniter\Session\Handlers\BaseHandler;
use CodeIgniter\Session\Handlers\DatabaseHandler; use CodeIgniter\Session\Handlers\DatabaseHandler;
use CodeIgniter\Session\Handlers\FileHandler; use CodeIgniter\Session\Handlers\FileHandler;
@@ -139,7 +138,11 @@ class Session extends BaseConfig
$this->driver = FileHandler::class; $this->driver = FileHandler::class;
$this->savePath = WRITEPATH . 'session'; $this->savePath = WRITEPATH . 'session';
} }
} catch (DatabaseException $e) { } catch (\Exception $e) {
// Database not available yet (e.g. fresh install before migrations).
// Fall back to file-based sessions so the login/migration page
// can still be served. Catches mysqli_sql_exception which is
// not a subclass of DatabaseException but is a RuntimeException.
$this->driver = FileHandler::class; $this->driver = FileHandler::class;
$this->savePath = WRITEPATH . 'session'; $this->savePath = WRITEPATH . 'session';
} }

View File

@@ -28,12 +28,9 @@ abstract class BaseController extends Controller
// protected $session; // protected $session;
/** /**
* @param RequestInterface $request
* @param ResponseInterface $response
* @param LoggerInterface $logger
* @return void * @return void
*/ */
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger): void public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{ {
// Load here all helpers you want to be available in your controllers that extend BaseController. // Load here all helpers you want to be available in your controllers that extend BaseController.
// Caution: Do not put the this below the parent::initController() call below. // Caution: Do not put the this below the parent::initController() call below.

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -154,8 +154,23 @@ class Items extends Secure_Controller
{ {
helper('file'); helper('file');
$pic_filename = rawurldecode($pic_filename); // Security: Sanitize filename to prevent path traversal
$file_extension = pathinfo($pic_filename, PATHINFO_EXTENSION); // Use basename() to strip directory components and prevent '../' attacks
$pic_filename = basename(rawurldecode($pic_filename));
$file_extension = strtolower(pathinfo($pic_filename, PATHINFO_EXTENSION));
// Validate file extension against system-configured allowed image types
// Handle both legacy pipe-separated and current comma-separated formats
// Fallback to types that GD library can process for thumbnail generation
$allowed_types = $this->config['image_allowed_types'] ?? 'jpg,jpeg,gif,png,webp,bmp,tif,tiff';
$allowed_extensions = strpos($allowed_types, '|') !== false
? explode('|', $allowed_types)
: explode(',', $allowed_types);
if (!in_array($file_extension, $allowed_extensions, true)) {
return $this->response->setStatusCode(400)->setBody('Invalid file type');
}
$images = glob("./uploads/item_pics/$pic_filename"); $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);
@@ -482,9 +497,9 @@ class Items extends Secure_Controller
foreach ($result as &$item) { foreach ($result as &$item) {
if (isset($item['item_number']) && empty($item['item_number']) && $this->config['barcode_generate_if_empty']) { if (isset($item['item_number']) && empty($item['item_number']) && $this->config['barcode_generate_if_empty']) {
if (isset($item['item_id'])) { if (isset($item['item_id'])) {
$save_item = ['item_number' => $item['item_number'], 'item_id' => $item['item_id']]; $save_item = ['item_number' => $item['item_number']];
$this->item->saveValue($save_item); $this->item->save_value($save_item, $item['item_id']);
} }
} }
} }
$data['items'] = $result; $data['items'] = $result;
@@ -663,12 +678,7 @@ class Items extends Secure_Controller
$employee_id = $this->employee->get_logged_in_employee_info()->person_id; $employee_id = $this->employee->get_logged_in_employee_info()->person_id;
// For updates, include item_id in data array if ($this->item->save_value($item_data, $item_id)) {
if ($item_id !== NEW_ENTRY) {
$item_data['item_id'] = $item_id;
}
if ($this->item->saveValue($item_data)) {
$success = true; $success = true;
$new_item = false; $new_item = false;
@@ -831,8 +841,8 @@ class Items extends Secure_Controller
*/ */
public function getRemoveLogo($item_id): ResponseInterface public function getRemoveLogo($item_id): ResponseInterface
{ {
$item_data = ['pic_filename' => null, 'item_id' => $item_id]; $item_data = ['pic_filename' => null];
$result = $this->item->saveValue($item_data); $result = $this->item->save_value($item_data, $item_id);
return $this->response->setJSON(['success' => $result]); return $this->response->setJSON(['success' => $result]);
} }
@@ -1044,15 +1054,21 @@ class Items extends Secure_Controller
return $value !== null && strlen($value); return $value !== null && strlen($value);
}); });
if (!$isFailedRow && $this->item->saveValue($itemData)) { if (!$isFailedRow && $this->item->save_value($itemData, $itemId)) {
$this->save_tax_data($row, $itemData); if (!$this->save_tax_data($row, $itemData)) {
$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId); $isFailedRow = true;
}
if (!$this->save_inventory_quantities($row, $itemData, $allowedStockLocations, $employeeId)) {
$isFailedRow = true;
}
$csvAttributeValues = $this->extractAttributeData($row); $csvAttributeValues = $this->extractAttributeData($row);
$isFailedRow = !$this->attribute->saveCSVRowAttributeData($csvAttributeValues, $itemData, $attributeData); if (!$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 attributes."); log_message('error', "CSV Item import failed on line $failedRow while saving item.");
continue; continue;
} }
@@ -1242,13 +1258,15 @@ 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): void private function save_inventory_quantities(array $row, array $item_data, array $allowed_locations, int $employee_id): bool
{ {
// 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];
@@ -1262,20 +1280,22 @@ 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"];
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id); $success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = $row["location_$location_name"]; $csv_data['trans_inventory'] = $row["location_$location_name"];
$this->inventory->insert($csv_data, false); $success &= (bool)$this->inventory->insert($csv_data, false);
} elseif ($is_update) { } elseif ($is_update) {
return; continue;
} else { } else {
$item_quantity_data['quantity'] = 0; $item_quantity_data['quantity'] = 0;
$this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id); $success &= $this->item_quantity->save_value($item_quantity_data, $item_data['item_id'], $location_id);
$csv_data['trans_inventory'] = 0; $csv_data['trans_inventory'] = 0;
$this->inventory->insert($csv_data, false); $success &= (bool)$this->inventory->insert($csv_data, false);
} }
} }
return (bool)$success;
} }
/** /**
@@ -1283,8 +1303,9 @@ 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): void private function save_tax_data(array $row, array $item_data): bool
{ {
$items_taxes_data = []; $items_taxes_data = [];
@@ -1296,9 +1317,11 @@ 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 (isset($items_taxes_data)) { if (!empty($items_taxes_data)) {
$this->item_taxes->save_value($items_taxes_data, $item_data['item_id']); return $this->item_taxes->save_value($items_taxes_data, $item_data['item_id']);
} }
return true;
} }
/** /**
@@ -1317,8 +1340,8 @@ class Items extends Secure_Controller
$images = glob(FCPATH . "uploads/item_pics/$item->pic_filename.*"); $images = glob(FCPATH . "uploads/item_pics/$item->pic_filename.*");
if (sizeof($images) > 0) { if (sizeof($images) > 0) {
$new_pic_filename = pathinfo($images[0], PATHINFO_BASENAME); $new_pic_filename = pathinfo($images[0], PATHINFO_BASENAME);
$item_data = ['pic_filename' => $new_pic_filename, 'item_id' => $item->item_id]; $item_data = ['pic_filename' => $new_pic_filename];
$this->item->saveValue($item_data); $this->item->save_value($item_data, $item->item_id);
} }
} }
} }

View File

@@ -49,6 +49,13 @@ 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' => [
@@ -62,13 +69,6 @@ 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,13 +1246,15 @@ 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'),
'invoices' => lang('Sales.invoice') 'bank_transfer' => lang('Sales.bank_transfer'),
'wallet' => lang('Sales.wallet'),
'invoices' => lang('Sales.invoice')
]; ];
} }

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,6 +2,7 @@
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
@@ -17,7 +18,37 @@ 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

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

View File

@@ -327,19 +327,6 @@ 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
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 AUTO_INCREMENT=1; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
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

@@ -22,7 +22,7 @@ function current_language_code(bool $load_system_language = false): string
} }
} }
return $config->language_code ?? DEFAULT_LANGUAGE_CODE; return $config['language_code'] ?? DEFAULT_LANGUAGE_CODE;
} }
/** /**
@@ -43,7 +43,7 @@ function current_language(bool $load_system_language = false): string
} }
} }
return $config->language ?? DEFAULT_LANGUAGE_CODE; return $config['language'] ?? DEFAULT_LANGUAGE;
} }
/** /**
@@ -365,6 +365,74 @@ 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,6 +172,7 @@ 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,6 +9,7 @@ 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" => "",
@@ -223,6 +224,7 @@ 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,332 +1,344 @@
<?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", "system_conf" => "Setup & Conf",
"system_info" => "System Info", "system_info" => "System Info",
"table" => "Table", "table" => "Table",
"table_configuration" => "Table Configuration", "table_configuration" => "Table Configuration",
"takings_printer" => "Receipt Printer", "takings_printer" => "Receipt Printer",
"tax" => "Tax", "tax" => "Tax",
"tax_category" => "Tax Category", "tax_category" => "Tax Category",
"tax_category_duplicate" => "The entered tax category already exists.", "tax_category_duplicate" => "The entered tax category already exists.",
"tax_category_invalid_chars" => "The entered tax category is invalid.", "tax_category_invalid_chars" => "The entered tax category is invalid.",
"tax_category_required" => "The tax category is required.", "tax_category_required" => "The tax category is required.",
"tax_category_used" => "Tax category cannot be deleted because it is being used.", "tax_category_used" => "Tax category cannot be deleted because it is being used.",
"tax_configuration" => "Tax Configuration", "tax_configuration" => "Tax Configuration",
"tax_decimals" => "Tax Decimals", "tax_decimals" => "Tax Decimals",
"tax_id" => "Tax Id", "tax_id" => "Tax Id",
"tax_included" => "Tax Included", "tax_included" => "Tax Included",
"theme" => "Theme", "theme" => "Theme",
"theme_preview" => "Preview Theme:", "theme_preview" => "Preview Theme:",
"thousands_separator" => "Thousands Separator", "thousands_separator" => "Thousands Separator",
"timezone" => "Timezone", "timezone" => "Timezone",
"timezone_error" => "OSPOS Timezone is Different from your Local Timezone.", "timezone_error" => "OSPOS Timezone is Different from your Local Timezone.",
"top" => "Top", "top" => "Top",
"use_destination_based_tax" => "Use Destination Based Tax", "use_destination_based_tax" => "Use Destination Based Tax",
"user_timezone" => "Local Timezone:", "user_timezone" => "Local Timezone:",
"website" => "Website", "website" => "Website",
"wholesale_markup" => "", "wholesale_markup" => "",
"work_order_enable" => "Work Order Support", "work_order_enable" => "Work Order Support",
"work_order_format" => "Work Order Format", "work_order_format" => "Work Order Format",
]; ];

View File

@@ -7,6 +7,7 @@ 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",
@@ -19,6 +20,8 @@ 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",
@@ -40,6 +43,7 @@ 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)",
@@ -73,12 +77,6 @@ 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",
@@ -109,6 +107,7 @@ 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",
@@ -150,7 +149,9 @@ 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.",
@@ -166,10 +167,13 @@ 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",
@@ -181,6 +185,7 @@ 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",
@@ -211,6 +216,7 @@ 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",
@@ -230,3 +236,5 @@ 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

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

View File

@@ -9,6 +9,7 @@ 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",
@@ -222,6 +223,7 @@ 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,6 +9,7 @@ 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" => "",
@@ -222,6 +223,7 @@ 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,6 +9,7 @@ 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" => "",
@@ -222,6 +223,7 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -82,4 +82,40 @@ class Email_lib
return $result; return $result;
} }
/**
* Gets the mime type of the company logo file.
*
* @return string Mime type or empty string if logo doesn't exist
*/
public function getLogoMimeType(): string
{
$logo_path = FCPATH . 'uploads/' . $this->config['company_logo'];
if (!empty($this->config['company_logo']) && file_exists($logo_path)) {
$mimeType = mime_content_type($logo_path);
return $mimeType !== false ? $mimeType : '';
}
return '';
}
/**
* Builds an img tag for the company logo to use in email templates.
*
* @return string HTML img tag with base64-encoded logo, or empty string if no logo
*/
public function buildLogoImgTag(): string
{
$mimeType = $this->getLogoMimeType();
if ($mimeType === '') {
return '';
}
$logo_path = FCPATH . 'uploads/' . $this->config['company_logo'];
$logo_data = base64_encode(file_get_contents($logo_path));
return '<img id="image" src="data:' . $mimeType . ';base64,' . $logo_data . '" alt="company_logo">';
}
} }

View File

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

View File

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

@@ -65,8 +65,10 @@ class Item extends Model
public function exists(string $item_id, bool $ignore_deleted = false, bool $deleted = false): bool public function exists(string $item_id, bool $ignore_deleted = false, bool $deleted = false): bool
{ {
$builder = $this->db->table('items'); $builder = $this->db->table('items');
$builder->groupStart();
$builder->where('item_id', $item_id); $builder->where('item_id', $item_id);
$builder->orWhere('item_number', $item_id); $builder->orWhere('item_number', $item_id);
$builder->groupEnd();
if (!$ignore_deleted) { if (!$ignore_deleted) {
$builder->where('deleted', $deleted); $builder->where('deleted', $deleted);
@@ -389,9 +391,10 @@ class Item extends Model
public function get_item_id(string $item_number, bool $ignore_deleted = false, bool $deleted = false): bool|int public function get_item_id(string $item_number, bool $ignore_deleted = false, bool $deleted = false): bool|int
{ {
$builder = $this->db->table('items'); $builder = $this->db->table('items');
$builder->join('suppliers', 'suppliers.person_id = items.supplier_id', 'left'); $builder->groupStart();
$builder->where('item_number', $item_number); $builder->where('item_number', $item_number);
$builder->orWhere('item_id', $item_number); $builder->orWhere('item_id', $item_number);
$builder->groupEnd();
if (!$ignore_deleted) { if (!$ignore_deleted) {
$builder->where('items.deleted', $deleted); $builder->where('items.deleted', $deleted);
@@ -436,62 +439,32 @@ class Item extends Model
/** /**
* Inserts or updates an item * Inserts or updates an item
*
* If the primary key (item_id) is present in the data array and the record exists,
* it will update the existing record. Otherwise, it will insert a new record.
*
* @param array $data The item data to save (passed by reference to set item_id on insert)
* @return bool True on success, false on failure
*/ */
public function saveValue(array &$data): bool public function save_value(array &$item_data, int $item_id = NEW_ENTRY): bool // TODO: need to bring this in line with parent or change the name
{ {
$primaryKey = $this->primaryKey;
$id = $data[$primaryKey] ?? NEW_ENTRY;
// If id > 0 and record exists by primary key only, update it
if ($id > 0) {
// Check existence strictly by primary key (regardless of soft-delete status)
$builder = $this->db->table('items');
$builder->where($primaryKey, $id);
$exists = $builder->countAllResults() > 0;
if ($exists) {
// Remove primary key from data array for update
$updateData = $data;
unset($updateData[$primaryKey]);
$builder = $this->db->table('items');
$builder->where($primaryKey, $id);
return $builder->update($updateData);
}
}
// Insert new record with transaction for atomicity
$this->db->transBegin();
// Remove primary key from insert payload if present
$insertData = $data;
unset($insertData[$primaryKey]);
$builder = $this->db->table('items'); $builder = $this->db->table('items');
$success = $builder->insert($insertData);
if ($item_id < 1 || !$this->exists($item_id, true)) {
if ($success) { if ($builder->insert($item_data)) {
$data[$primaryKey] = (int)$this->db->insertID(); $item_data['item_id'] = (int)$this->db->insertID();
if ($item_id < 1) {
// Update low_sell_item_id for new items $builder = $this->db->table('items');
$builder = $this->db->table('items'); $builder->where('item_id', $item_data['item_id']);
$builder->where($primaryKey, $data[$primaryKey]); $builder->update(['low_sell_item_id' => $item_data['item_id']]);
$success = $builder->update(['low_sell_item_id' => $data[$primaryKey]]); }
return true;
}
return false;
} else {
$item_data['item_id'] = $item_id;
} }
if ($success) { $builder = $this->db->table('items');
$this->db->transCommit(); $builder->where('item_id', $item_id);
return true;
} return $builder->update($item_data);
$this->db->transRollback();
return false;
} }
/** /**
@@ -1109,9 +1082,9 @@ class Item extends Model
$total_quantity = $old_total_quantity + $items_received; $total_quantity = $old_total_quantity + $items_received;
$average_price = bcdiv(bcadd(bcmul((string)$items_received, (string)$new_price), bcmul((string)$old_total_quantity, (string)$old_price)), (string)$total_quantity); $average_price = bcdiv(bcadd(bcmul((string)$items_received, (string)$new_price), bcmul((string)$old_total_quantity, (string)$old_price)), (string)$total_quantity);
$data = ['cost_price' => $average_price, 'item_id' => $item_id]; $data = ['cost_price' => $average_price];
return $this->saveValue($data); return $this->save_value($data, $item_id);
} }
/** /**

View File

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

View File

@@ -277,6 +277,14 @@ class Sale extends Model
$builder->like('payment_type', lang('Sales.debit')); $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();
@@ -1509,5 +1517,13 @@ 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

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

View File

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

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,249 @@
<?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

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

View File

@@ -0,0 +1,224 @@
<?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

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

View File

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

View File

@@ -2,11 +2,14 @@
/** /**
* @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') ?>
@@ -61,6 +64,6 @@ if (isset($error_message)) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<?= view('sales/' . $config['receipt_template']) ?> <?= view('sales/' . $template) ?>
<?= view('partial/footer') ?> <?= view('partial/footer') ?>

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

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

8
composer.lock generated
View File

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

View File

@@ -46,6 +46,7 @@ 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,6 +16,7 @@ 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

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

1712
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

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

View File

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

9
spark
View File

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

View File

@@ -18,6 +18,7 @@ class AppTest extends CIUnitTestCase
// Clean up environment // 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']);
} }
@@ -281,4 +282,106 @@ 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');
}
} }

View File

@@ -237,7 +237,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$row = $this->db->table('items') $row = $this->db->table('items')
->where('item_number', $itemData['item_number']) ->where('item_number', $itemData['item_number'])
@@ -268,7 +268,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$locationId = 1; $locationId = 1;
$quantity = 100; $quantity = 100;
@@ -298,7 +298,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$inventoryData = [ $inventoryData = [
'trans_inventory' => 50, 'trans_inventory' => 50,
@@ -329,7 +329,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$taxesData = [ $taxesData = [
['name' => 'VAT', 'percent' => 20], ['name' => 'VAT', 'percent' => 20],
@@ -406,7 +406,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => false 'deleted' => false
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
} }
$item1 = $this->item->get_info_by_id_or_number('ITEM-A'); $item1 = $this->item->get_info_by_id_or_number('ITEM-A');
@@ -430,7 +430,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($originalData)); $this->assertTrue($this->item->save_value($originalData));
$updatedData = [ $updatedData = [
'item_id' => $originalData['item_id'], 'item_id' => $originalData['item_id'],
@@ -443,7 +443,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($updatedData)); $this->assertTrue($this->item->save_value($updatedData, $updatedData['item_id']));
$updatedItem = $this->item->get_info($updatedData['item_id']); $updatedItem = $this->item->get_info($updatedData['item_id']);
$this->assertEquals('Updated Name', $updatedItem->name); $this->assertEquals('Updated Name', $updatedItem->name);
@@ -464,7 +464,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($originalData)); $this->assertTrue($this->item->save_value($originalData));
$definitionData = [ $definitionData = [
'definition_name' => 'Color', 'definition_name' => 'Color',
@@ -510,7 +510,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$definitionData = [ $definitionData = [
'definition_name' => 'Color', 'definition_name' => 'Color',
@@ -553,7 +553,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
// Mock Attribute DROPDOWN // Mock Attribute DROPDOWN
$definitionData = [ $definitionData = [
@@ -604,7 +604,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$locationId = 1; $locationId = 1;
@@ -633,7 +633,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$savedItem = $this->item->get_info($itemData['item_id']); $savedItem = $this->item->get_info($itemData['item_id']);
$this->assertEquals(-1, (int)$savedItem->reorder_level); $this->assertEquals(-1, (int)$savedItem->reorder_level);
@@ -672,7 +672,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$savedItem = $this->item->get_info($itemData['item_id']); $savedItem = $this->item->get_info($itemData['item_id']);
@@ -702,7 +702,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$savedItem = $this->item->get_info($itemData['item_id']); $savedItem = $this->item->get_info($itemData['item_id']);
$this->assertEquals('8471', $savedItem->hsn_code); $this->assertEquals('8471', $savedItem->hsn_code);
@@ -719,7 +719,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$locations = [ $locations = [
'Warehouse' => 100, 'Warehouse' => 100,
@@ -792,7 +792,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$this->assertIsInt($itemData['item_id']); $this->assertIsInt($itemData['item_id']);
$this->assertGreaterThan(0, $itemData['item_id']); $this->assertGreaterThan(0, $itemData['item_id']);
@@ -812,7 +812,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$exists = $this->item->exists($itemData['item_id']); $exists = $this->item->exists($itemData['item_id']);
$this->assertTrue($exists); $this->assertTrue($exists);
@@ -858,7 +858,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$taxesData = []; $taxesData = [];
if (is_numeric($csvRow['Tax 1 Percent']) && $csvRow['Tax 1 Name'] !== '') { if (is_numeric($csvRow['Tax 1 Percent']) && $csvRow['Tax 1 Name'] !== '') {
@@ -1032,7 +1032,7 @@ class ItemsCsvImportTest extends CIUnitTestCase
'deleted' => 0 'deleted' => 0
]; ];
$this->assertTrue($this->item->saveValue($itemData)); $this->assertTrue($this->item->save_value($itemData));
$uniqueId = uniqid(); $uniqueId = uniqid();
$locations = ['Warehouse' . $uniqueId, 'Store' . $uniqueId]; $locations = ['Warehouse' . $uniqueId, 'Store' . $uniqueId];