Compare commits

...

17 Commits

Author SHA1 Message Date
Ollama
39864c4e86 Introduce RewardOperation enum for type-safe reward operations
Per code review feedback from @objecttothis:

- Create app/Enums/RewardOperation.php with Deduct, Restore, Adjust cases
- Update Reward_lib::adjustRewardPoints() to use RewardOperation enum
  instead of string parameter for operation type
- Update tests to use RewardOperation::Deduct/Restore enum values

Benefits:
- Type safety: Cannot pass invalid operation string
- IDE autocomplete support for operation types
- Compile-time error for typos instead of runtime failure
- Self-documenting code with explicit cases
2026-03-22 20:03:01 +00:00
Ollama
423c06142c Prevent negative reward points and address CodeRabbit review comments
CodeRabbit issues addressed:

1. Negative points prevention in Reward_lib:
   - adjustRewardPoints(): Validate sufficient balance before deduct/adjust
   - handleCustomerChange(): Cap charge at available points, add 'insufficient' flag
   - adjustRewardDelta(): Validate sufficient points for positive adjustments

2. Sale.php fixes:
   - Add null coalescing for reward points in processPaymentType()
   - Validate giftcard payment format before accessing array index
   - Remove unused loop variables $paymentId and $line
   - Add null check for deleted customer in delete() method
   - Log warnings when insufficient points detected

3. Test coverage:
   - Add test for exact points match (hasSufficientPoints)
   - Add tests for insufficient points scenarios
   - Add tests for negative adjustment (refund)
   - Add tests for handleCustomerChange caps

All changes prevent customers from having negative reward point balances.
2026-03-22 19:56:04 +00:00
Ollama
a240c933fd Add Reward_lib import to Sale model 2026-03-17 15:01:35 +00:00
Ollama
66b6c99384 Extract reward logic to Reward_lib library and add unit tests
- Create app/Libraries/Reward_lib.php with reward point business logic:
  - calculatePointsEarned(): calculate rewards from purchase amount
  - adjustRewardPoints(): deduct/restore/adjust customer points
  - handleCustomerChange(): handle points when customer changes on sale
  - adjustRewardDelta(): adjust delta for same customer payment changes
  - hasSufficientPoints(): validate customer has enough points
  - getPointsBalance(): get customer points balance
  - calculateRewardPaymentAmount(): sum reward payments from array

- Add tests/Libraries/Reward_libTest.php with 20+ test cases covering:
  - Points calculation edge cases
  - Customer change scenarios
  - Deletion/restore operations
  - Sufficient points validation
  - Payment amount calculation

- Update tests/phpunit.xml to include Libraries testsuite

This extraction centralizes reward logic for better testability and
follows the existing library pattern (Tax_lib, Sale_lib, etc.).
2026-03-17 15:00:57 +00:00
Ollama
a1c062ab13 PSR-12: Refactor snake_case variables to camelCase and extract helper method
In update(), save_value(), delete():
- Rename $payment_id, $payment_type, etc. to camelCase equivalents
- Rename $sales_payments_data, $sales_data, $sales_items_data to camelCase
- Rename $total_amount, $total_amount_used to $totalAmount, $totalAmountUsed
- Rename $cur_item_info, $item_quantity_data to $currentItemInfo, $itemQuantityData
- Rename $sale_remarks, $inv_data to $saleRemarks, $inventoryData

Extract new helper:
- processPaymentType() handles giftcard deduction and reward point processing
  during sale creation, reducing complexity in save_value()

Resolves TODO comments in save_value() about snake_case variables
2026-03-16 18:26:10 +00:00
Ollama
003df2bd7c PSR-12: Convert snake_case to camelCase for reward methods
- Rename is_reward_payment() to isRewardPayment()
- Rename get_reward_payment_labels() to getRewardPaymentLabels()
- Rename $language_paths to $languagePaths
- Rename $sales_file to $salesFile
2026-03-14 15:42:51 +00:00
Ollama
eec567ee15 Address CodeRabbit review comments
- Make sale update transaction atomic by wrapping sale row update,
  payment processing, and reward point adjustments in a single transaction
- Fix customer_id fallback bug: use array_key_exists instead of null
  coalesce to preserve previous customer when customer_id is omitted
- Prevent double-crediting on delete: only restore reward points when
  sale_status is not already CANCELED
- Remove sensitive payment data from debug logs: replace json_encode
  with aggregated values (count and totals)
2026-03-13 18:44:44 +00:00
Furzi
e7c610acd0 Refactor reward variables to camelCase 2026-03-11 14:15:32 +01:00
Furzi
cff8762d07 Fix customer reward points not updating correctly when editing or deleting sales 2026-03-11 14:15:32 +01:00
dependabot[bot]
85889b6e65 Bump jspdf from 4.1.0 to 4.2.0 (#4383)
Bumps [jspdf](https://github.com/parallax/jsPDF) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/parallax/jsPDF/releases)
- [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md)
- [Commits](https://github.com/parallax/jsPDF/compare/v4.1.0...v4.2.0)

---
updated-dependencies:
- dependency-name: jspdf
  dependency-version: 4.2.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>
Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-03-11 16:36:53 +04:00
Ollama
6818f02ef9 Update SECURITY.md with published security advisories
- Add Security Advisories section with 4 published CVEs
- Include CVE ID, vulnerability description, CVSS score, publication date, fixed version, and reporter credits
- Update supported versions table to reflect current state (>= 3.4.2)
- Add link to GitHub Security Advisories page for complete list

CVEs added:
- CVE-2025-68434: CSRF leading to Admin Creation (8.8)
- CVE-2025-68147: Stored XSS in Return Policy (8.1)
- CVE-2025-66924: Stored XSS in Item Kits (7.2)
- CVE-2025-68658: Stored XSS in Company Name (4.3)
2026-03-10 22:28:09 +01:00
Ollama
436696b11b Add workflow to auto-update issue templates with releases
Adds a GitHub Actions workflow that automatically updates the
OpensourcePOS Version dropdown in bug report and feature request
templates when new releases are published.

Fixes #4317
2026-03-10 22:26:49 +01:00
Ollama
9a2b308647 Sync language files (#3468)
- Add csv_import_invalid_location to Items.php for CSV import validation
- Add error_deleting_admin and error_updating_admin to Employees.php for admin protection messages

Strings added with empty values so they fallback to English and show as untranslated in Weblate.
2026-03-09 07:45:19 +01:00
Ollama
1f55d96580 Fix mass assignment vulnerability in bulk edit (GHSA-49mq-h2g4-grr9)
The bulk edit function iterated over all $_POST keys without a whitelist,
allowing authenticated users to inject arbitrary database columns (e.g.,
cost_price, deleted, item_type) into the update query. This bypassed
CodeIgniter 4's $allowedFields protection since Query Builder was used
directly.

Fix: Add ALLOWED_BULK_EDIT_FIELDS constant to Item model defining the
explicit whitelist of fields that can be bulk-updated. Use this constant
in the controller instead of iterating over $_POST directly.

Fields allowed: name, category, supplier_id, cost_price, unit_price,
reorder_level, description, allow_alt_description, is_serialized

Security impact: High (CVSS 8.1) - Could allow price manipulation and
data integrity violations.
2026-03-08 22:49:12 +01:00
Ollama
b2fadea44a Fix broken SQL injection fix - use havingLike() instead of having() with named params
The previous SQL injection fix (GHSA-hmjv-wm3j-pfhw) used named parameter
syntax :search: with having(), but CodeIgniter 4's having() method does
not support named parameters. This caused the query to fail.

The fix uses havingLike() which properly:
- Escapes the search value to prevent SQL injection
- Handles the LIKE clause construction internally (wraps value with %)
- Works correctly with HAVING clauses for aggregated columns

This maintains the security fix while actually working on CI4.
2026-03-08 22:48:43 +01:00
Ollama
0fdb3ba37b Fix payment type becoming null when editing sales
When localization uses dot (.) as thousands separator (e.g., it_IT, es_ES, pt_PT),
the payment_amount value was displayed as raw float (e.g., '10.50') but parsed
using parse_decimals() which expects locale-formatted numbers.

In these locales, '.' is thousands separator and ',' is decimal separator.
parse_decimals('10.50') would return false, causing the condition
 != 0 to evaluate incorrectly (false == 0 in PHP),
resulting in the payment being deleted instead of updated.

Fix: Use to_currency_no_money() to format payment_amount and cash_refund
values according to locale before displaying in the form, so parse_decimals()
can correctly parse them on submission.
2026-03-08 22:34:47 +01:00
jekkos
d7b2264ac1 Fix: Preserve CHECKBOX attribute state when adding attributes (#4385)
Modified definition_values() function in app/Views/attributes/item.php to properly handle checkbox attributes.

The issue was that checkbox attributes have two input elements (hidden and checkbox) with the same name pattern. When collecting attribute values during the refresh operation, both inputs were being processed, with the hidden input potentially overwriting the checkbox state.

Changes:
- Skip hidden inputs that have a corresponding checkbox input
- For checkbox inputs, explicitly capture the checked state using prop('checked')
- Convert checked state to '1' or '0' for consistency

This ensures that when adding another attribute to an item, existing checkbox states are preserved correctly.
2026-03-08 22:31:02 +01:00
99 changed files with 1251 additions and 140 deletions

View File

@@ -0,0 +1,72 @@
name: Update Issue Templates
on:
release:
types: [published]
workflow_dispatch:
schedule:
- cron: '0 0 * * 0'
jobs:
update-templates:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch releases and update templates
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Fetch releases from GitHub API
RELEASES=$(gh api repos/${{ github.repository }}/releases --jq '.[].tag_name' | head -n 10)
# Create temporary file with options
OPTIONS_FILE=$(mktemp)
echo " - development (unreleased)" >> "$OPTIONS_FILE"
while IFS= read -r release; do
echo " - opensourcepos $release" >> "$OPTIONS_FILE"
done <<< "$RELEASES"
update_template() {
local template="$1"
local template_path=".github/ISSUE_TEMPLATE/$template"
# Find the line numbers for the OpensourcePOS Version dropdown
start_line=$(grep -n "label: OpensourcePOS Version" "$template_path" | cut -d: -f1)
if [ -z "$start_line" ]; then
echo "Could not find OpensourcePOS Version in $template"
return 1
fi
# Find the options section and default line
options_start=$((start_line + 3))
default_line=$(grep -n "default:" "$template_path" | awk -F: -v opts="$options_start" '$1 > opts {print $1; exit}')
# Create new template file
head -n $((options_start - 1)) "$template_path" > "${template_path}.new"
cat "$OPTIONS_FILE" >> "${template_path}.new"
tail -n +$default_line "$template_path" >> "${template_path}.new"
mv "${template_path}.new" "$template_path"
echo "Updated $template"
}
update_template "bug report.yml"
update_template "feature_request.yml"
- name: Commit and push changes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .github/ISSUE_TEMPLATE/*.yml
if git diff --staged --quiet; then
echo "No changes to commit"
else
git commit -m "Update issue templates with latest releases [skip ci]"
git push
fi

View File

@@ -1,9 +1,9 @@
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Security Policy](#security-policy)
- [Supported Versions](#supported-versions)
- [Security Advisories](#security-advisories)
- [Reporting a Vulnerability](#reporting-a-vulnerability)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
@@ -12,14 +12,35 @@
## Supported Versions
We release patches for security vulnerabilities. Which versions are eligible to receive such patches depend on the CVSS v3.0 Rating:
We release patches for security vulnerabilities.
| CVSS v3.0 | Supported Versions |
| --------- | -------------------------------------------------- |
| 7.3 | 3.3.5 |
| 9.8 | 3.3.6 |
| 6.8 | 3.4.2 |
| Version | Supported |
| --------- | ------------------ |
| >= 3.4.2 | :white_check_mark: |
| < 3.4.2 | :x: |
## Security Advisories
The following security vulnerabilities have been published:
### High Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68434](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-wjm4-hfwg-5w5r) | CSRF leading to Admin Creation | 8.8 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-68147](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-xgr7-7pvw-fpmh) | Stored XSS in Return Policy | 8.1 | 2025-12-17 | 3.4.2 | @Nixon-H, @jekkos |
| [CVE-2025-66924](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-gv8j-f6gq-g59m) | Stored XSS in Item Kits | 7.2 | 2026-03-04 | 3.4.2 | @hungnqdz, @omkaryepre |
### Medium Severity
| CVE | Vulnerability | CVSS | Published | Fixed In | Credit |
|-----|--------------|------|-----------|----------|--------|
| [CVE-2025-68658](https://github.com/opensourcepos/opensourcepos/security/advisories/GHSA-32r8-8r9r-9chw) | Stored XSS in Company Name | 4.3 | 2026-01-13 | 3.4.2 | @hungnqdz |
For a complete list including draft advisories, see our [GitHub Security Advisories page](https://github.com/opensourcepos/opensourcepos/security/advisories).
## Reporting a Vulnerability
Please report (suspected) security vulnerabilities to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**. 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.
Please report (suspected) security vulnerabilities to **[jeroen@steganos.dev](mailto:jeroen@steganos.dev)**.
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.

View File

@@ -876,12 +876,12 @@ class Items extends Secure_Controller
$items_to_update = $this->request->getPost('item_ids');
$item_data = [];
foreach ($_POST as $key => $value) {
// This field is nullable, so treat it differently
if ($key === 'supplier_id' && $value !== '') {
$item_data[$key] = $value;
} elseif ($value !== '' && !(in_array($key, ['item_ids', 'tax_names', 'tax_percents']))) {
$item_data[$key] = $value;
foreach (Item::ALLOWED_BULK_EDIT_FIELDS as $field) {
$value = $this->request->getPost($field);
if ($field === 'supplier_id' && $value !== '') {
$item_data[$field] = $value;
} elseif ($value !== null && $value !== '') {
$item_data[$field] = $value;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Enums;
/**
* Reward operation types for customer points adjustments.
*
* Used by Reward_lib to perform type-safe reward point operations.
*/
enum RewardOperation: string
{
case Deduct = 'deduct';
case Restore = 'restore';
case Adjust = 'adjust';
}

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "كلمة المرور الحالية غير صحيحة.",
"employee" => "موظف",
"error_adding_updating" => "خطاء فى إضافة/تعديل موظف.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "لايمكن حذف المستخدم admin الخاص بنسخة العرض.",
"error_updating_demo_admin" => "لايمكن تغيير بيانات المستخدم admin الخاص بنسخة العرض.",
"language" => "اللغة",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "سعر التكلفة مطلوب.",
"count" => "تحديث المخزون",
"csv_import_failed" => "فشل الإستيراد من اكسل",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "الملف الذى رفعته إما فارغ أو أنه مختلف البنية.",
"csv_import_partially_failed" => "يوجد خطأ بنسبة {0} في استيراد الاصناف في السطر: {1}. لم يتم استيرادهم.",
"csv_import_success" => "تم استيراد الأصناف بنجاح.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "كلمة المرور الحالية غير صحيحة.",
"employee" => "موظف",
"error_adding_updating" => "خطاء فى إضافة/تعديل موظف.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "لايمكن حذف المستخدم admin الخاص بنسخة العرض.",
"error_updating_demo_admin" => "لايمكن تغيير بيانات المستخدم admin الخاص بنسخة العرض.",
"language" => "اللغة",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "سعر التكلفة مطلوب.",
"count" => "تحديث المخزون",
"csv_import_failed" => "فشل الإستيراد من اكسل",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "الملف الذى رفعته إما فارغ أو أنه مختلف البنية.",
"csv_import_partially_failed" => "يوجد خطأ بنسبة {0} في استيراد الاصناف في السطر: {1}. لم يتم استيرادهم.",
"csv_import_success" => "تم استيراد الأصناف بنجاح.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Hazirki Şifrə düzgün deyil.",
"employee" => "Əməkdaş",
"error_adding_updating" => "Əməkdaş əlavə etməsk və ya yeniləməsi baş vermədi.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Demo administrator istifadəçisini silə bilməzsiniz.",
"error_updating_demo_admin" => "Demo administrator istifadəçisini dəyişə bilməzsiniz.",
"language" => "Dil",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Topdan satiış - doldurulması vacib sahə.",
"count" => "inventorun yenilənməsi",
"csv_import_failed" => "səhv csv import",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Yüklənmiş faylda məlumat yoxdur və ya düzgün formatlanmır.",
"csv_import_partially_failed" => "Xətlərdə {0} element idxalı uğursuzluq (lar) var: {1}. Heç bir sıra idxal edilmədi.",
"csv_import_success" => "Malların İdxalı Uğurla Həyata Keçdi.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Текущата парола е невалидна.",
"employee" => "Служител",
"error_adding_updating" => "Добавянето или актуализирането на служителите е неуспешно.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Не може да изтриете Пробният Администратор.",
"error_updating_demo_admin" => "Не може да промените Пробният Администратор.",
"language" => "Език",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Wholesale Price is a required field.",
"count" => "Update Inventory",
"csv_import_failed" => "CSV import failed",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "The uploaded file has no data or is formatted incorrectly.",
"csv_import_partially_failed" => "Item import successful with some failures:",
"csv_import_success" => "Item import successful.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Trenutna lozinka je nevažeća.",
"employee" => "Zaposlenik",
"error_adding_updating" => "Dodavanje ili ažuriranje zaposlenika nije uspjelo.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Ne možete izbrisati demo korisnika administratora.",
"error_updating_demo_admin" => "Ne možete promijeniti korisnika demo administratora.",
"language" => "Jezik",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Fakturna cijena je obavezno polje.",
"count" => "Ažuriraj zalihu",
"csv_import_failed" => "Uvoz CSV-a nije uspio",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Učitana CSV datoteka nema podatke ili je pogrešno formatirana.",
"csv_import_partially_failed" => "Bilo je {0} grešaka pri uvozu stavke na liniji: {1}. Nijedan red nije uvezen.",
"csv_import_success" => "Uvoz CSV stavke je uspješan.",

View File

@@ -14,6 +14,8 @@ return [
'current_password_invalid' => "وشەی نهێنی ئێستا نادروستە.",
'employee' => "فەرمانبەر",
'error_adding_updating' => "زیادکردن یان نوێکردنەوەی کارمەند سەرکەوتوو نەبوو.",
'error_deleting_admin' => "",
'error_updating_admin' => "",
'error_deleting_demo_admin' => "ناتوانیت بەکارهێنەری ئەدمینی تاقیکردنەوەیی بسڕیتەوە.",
'error_updating_demo_admin' => "ناتوانیت بەکارهێنەری ئەدمین تاقیکردنەوەیی بگۆڕیت.",
'language' => "زمان",

View File

@@ -26,6 +26,7 @@ return [
'cost_price_required' => "نرخی جوملە خانەیەکی پێویستە.",
'count' => "جەرد نوێ بکەوە",
'csv_import_failed' => "هاوردەکردنی CSV سەرکەوتوو نەبوو",
'csv_import_invalid_location' => "",
'csv_import_nodata_wrongformat' => "پەڕگەی CSV بارکراو هیچ داتایەکی نییە یان بە هەڵە فۆرمات کراوە.",
'csv_import_partially_failed' => "{0} شکستی هاوردەکردنی بابەتی لەسەر هێڵەکان هەبوو: {1}. هیچ ڕیزێک هاوردە نەکرا.",
'csv_import_success' => "بابەتی هاوردەکردنی CSV سەرکەوتوو بوو.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "",
"error_adding_updating" => "",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "",
"error_updating_demo_admin" => "",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Musíte zadat nákupní cenu.",
"count" => "Upravit množství",
"csv_import_failed" => "Import z CSVu se nepovedl",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Nahraný soubor neobsahuje žádná data nebo má špatný formát.",
"csv_import_partially_failed" => "Při importu položek došlo k několika chybám:",
"csv_import_success" => "Import položek proběhl bez chyby.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Current Password is invalid.",
"employee" => "Employee",
"error_adding_updating" => "Employee add or update failed.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "You can not delete the demo admin user.",
"error_updating_demo_admin" => "You can not change the demo admin user.",
"language" => "Language",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "",
"count" => "",
"csv_import_failed" => "",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "",
"csv_import_partially_failed" => "",
"csv_import_success" => "",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "Mitarbeiter",
"error_adding_updating" => "Fehler beim Hinzufügen/Ändern",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Sie können den Admin nicht löschen",
"error_updating_demo_admin" => "Sie können den Admin nicht ändern",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Einstandspreis ist erforderlich",
"count" => "Ändere Bestand",
"csv_import_failed" => "CSV Import fehlerhaft",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Your uploaded file has no data or wrong format",
"csv_import_partially_failed" => "Most Items imported. But some were not, here is the list",
"csv_import_success" => "Import of Items successful",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Aktuelles Passwort ist ungültig.",
"employee" => "Mitarbeiter",
"error_adding_updating" => "Fehler beim Hinzufügen/Ändern.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Sie können den Demo-Administrator nicht löschen.",
"error_updating_demo_admin" => "Sie können den Demo-Administrator nicht verändern.",
"language" => "Sprache",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Der Großhandelspreis ist ein Pflichtfeld.",
"count" => "Ändere Bestand",
"csv_import_failed" => "CSV Import fehlgeschlagen",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Die hochgeladene Datei enthält keine Daten oder ist falsch formatiert.",
"csv_import_partially_failed" => "{0} Artikel-Import Fehler in Zeile: {1}. Keine Reihen wurden importiert.",
"csv_import_success" => "Artikelimport erfolgreich.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "",
"error_adding_updating" => "",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "",
"error_updating_demo_admin" => "",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "",
"count" => "",
"csv_import_failed" => "",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "",
"csv_import_partially_failed" => "",
"csv_import_success" => "",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Contraseña Actual Inválida.",
"employee" => "Empleado",
"error_adding_updating" => "Error al agregar/actualizar empleado.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "No puedes borrar el usuario admin del demo.",
"error_updating_demo_admin" => "No puedes cambiar el usuario admin del demo.",
"language" => "Idioma",

View File

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

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "La contraseña actual es inválida.",
"employee" => "Empleado",
"error_adding_updating" => "Agregar ó Actualizar empleado ha fallado.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "No puede borrar el usuario demo de administrador.",
"error_updating_demo_admin" => "No puede cambiar el usuario demo de administrador.",
"language" => "Idioma",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "El precio de mayoreo es requerido.",
"count" => "Actualizar inventario",
"csv_import_failed" => "",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "",
"csv_import_partially_failed" => "",
"csv_import_success" => "",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "گذرواژه فعلی نامعتبر است.",
"employee" => "کارمند",
"error_adding_updating" => "افزودن یا به روزرسانی کارکنان انجام نشد.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "شما نمی توانید کاربر مدیر نسخه ی نمایشی را حذف کنید.",
"error_updating_demo_admin" => "شما نمی توانید کاربر مدیر نسخه ی نمایشی را تغییر دهید.",
"language" => "زبان",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "قیمت عمده فروشی یک زمینه ضروری است.",
"count" => "به روزرسانی موجودی",
"csv_import_failed" => "واردات سی‌اس‌وی انجام نشد",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "پرونده سی‌اس‌وی آپلود شده داده ای ندارد یا به طور نادرست قالب بندی شده است.",
"csv_import_partially_failed" => "در خط (ها){0} شکست واردات کالا وجود دارد:{1}. هیچ سطر وارد نشده است.",
"csv_import_success" => "وارد کردن سی‌اس‌وی مورد موفقیت آمیز است.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Le mot de passe actuel est invalide.",
"employee" => "Employé",
"error_adding_updating" => "Erreur d'ajout/édition d'employé.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Vous ne pouvez pas supprimer l'utilisateur de démonstration admin.",
"error_updating_demo_admin" => "Vous ne pouvez pas modifier l'utilisateur de démonstration admin.",
"language" => "Langue",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Le prix de gros est requis.",
"count" => "Mise à jour de l'inventaire",
"csv_import_failed" => "Échec d'import CSV",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Le CSV envoyé ne contient aucune donnée, ou elles sont dans un format erroné.",
"csv_import_partially_failed" => "Il y a eu {0} importation(s) d'articles échoué(s) au(x) ligne(s) : {1}. Aucune ligne n'a été importée.",
"csv_import_success" => "Importation des articles réussie.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "הסיסמה הנוכחית אינה חוקית.",
"employee" => "עובד",
"error_adding_updating" => "הוספה או עדכון של עובד נכשלה.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "לא ניתן למחוק את משתמש המנהל ההדגמה.",
"error_updating_demo_admin" => "לא ניתן לשנות את משתמש המנהל ההדגמה.",
"language" => "שפה",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "מחיר סיטונאי הינו שדה חובה.",
"count" => "עדכן מלאי",
"csv_import_failed" => "ייבוא אקסל נכשל",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "בקובץ שהועלה אין נתונים או פורמט שגוי.",
"csv_import_partially_failed" => "ייבוא פריט הצליח עם מספר שגיאות:",
"csv_import_success" => "ייבוא הפריט הצליח.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "Radnik",
"error_adding_updating" => "Greška kod dodavanja/ažuriranja radnika",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Ne možete obrisati demo admin korisnika",
"error_updating_demo_admin" => "Ne možete promijeniti demo admin korisnika",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Nabavna cijena je potrebna",
"count" => "Ažuriraj inveturu",
"csv_import_failed" => "Greška kod uvoza iz CSV-a",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Your uploaded file has no data or wrong format",
"csv_import_partially_failed" => "Most Items imported. But some were not, here is the list",
"csv_import_success" => "Import of Items successful",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "Munkavállaló",
"error_adding_updating" => "Hiba a munkavállaló módosításánál/hozzáadásánál",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Nem tudja törölni a demo admin felhasználót",
"error_updating_demo_admin" => "Nem tudja módosítani a demo admin felhasználót",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Bekerülési ár kötelező mező",
"count" => "Raktárkészlet módosítása",
"csv_import_failed" => "CSV import sikertelen",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "A feltöltött fájlban nincs adat, vagy rossz formátum.",
"csv_import_partially_failed" => "Most Items imported. But some were not, here is the list",
"csv_import_success" => "Import of Items successful",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "",
"error_adding_updating" => "",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "",
"error_updating_demo_admin" => "",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "",
"count" => "",
"csv_import_failed" => "",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "",
"csv_import_partially_failed" => "",
"csv_import_success" => "",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Kata kunci sekarang salah.",
"employee" => "Karyawan",
"error_adding_updating" => "Kesalahan menambah / memperbarui karyawan.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Anda tidak dapat menghapus Demo admin user.",
"error_updating_demo_admin" => "Anda tidak dapat mengubah Demo admin user.",
"language" => "Bahasa",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Harga beli harus diisi.",
"count" => "Mutasi Inventori",
"csv_import_failed" => "Gagal impor CSV",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Berkas CSV terunggah tidak berisi data atau formatnya salah.",
"csv_import_partially_failed" => "Terdapat {0} item gagal impor pada baris: {1}. Tidak ada baris yang diimpor.",
"csv_import_success" => "Impor item CSV berhasil.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Password corrente non valida.",
"employee" => "Impiegato",
"error_adding_updating" => "Aggiunta o aggiornamento di impiegati fallito.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Non puoi eliminare l'utente admin demo.",
"error_updating_demo_admin" => "Non puoi cambiare l'utente admin demo.",
"language" => "Lingua",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Prezzo all'ingrosso è un campo obbligatorio.",
"count" => "Aggiorna Inventario",
"csv_import_failed" => "Importazione CSV fallita",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "L'upload del file non ha dati o non è formattato correttamente.",
"csv_import_partially_failed" => "Si sono verificati {0} errori di importazione degli elementi nelle righe: {1}. Nessuna riga è stata importata.",
"csv_import_success" => "Importazione CSV dell'articolo riuscita.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "ពាក្យសម្ងាត់បច្ចុប្បន្ន មិនត្រឹមត្រូវ។",
"employee" => "បុគ្គលិក",
"error_adding_updating" => "បន្ថែម ឬកែប្រែបុគ្គលិកមិនបានសំរេច។",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "អ្នកមិនអាចលុប គណនីសាកល្បង បានទេ។",
"error_updating_demo_admin" => "អ្នកមិនអាចកែប្រែ គណនីសាកល្បងបានទេ។",
"language" => "ភាសា",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "ត្រូវការតម្លៃលក់ដុំជាចាំបាច់។",
"count" => "កែប្រែ ទំនិញក្នុងស្តុក",
"csv_import_failed" => "CSV បញ្ចូលមិនបានសំរេច",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "ដាក់បញ្ជុល CSV មិនមានទិន្នន័យ ឬទំរង់មិនត្រឹមត្រូវ។",
"csv_import_partially_failed" => "មានទំននិញ {0} បញ្ជូលមិនបានសំរេច នៅជួរ: {1} ។ គ្មានជួរណាមួយត្រូវបានបញ្ជូលនោះទេ។",
"csv_import_success" => "ទំនិញក្នុង CSV បញ្ចូលបានសំរេច។",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Password ປັດຈຸບັນບໍ່ຖືກຕ້ອງ.",
"employee" => "ພະນັກງານ",
"error_adding_updating" => "ເພີ່ມ ຫຼື ແກ້ໄຂ ພະນັກງານ ບໍ່ສຳເລັດ.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "ທ່ານບໍ່ສາມາດລຶບບັນຊີທົດລອງຜູ້ດູແລລະບົບໄດ້.",
"error_updating_demo_admin" => "ທ່ານບໍ່ສາມາດປ່ຽນແປງບັນຊີທົດລອງຜູ້ດູແລລະບົບໄດ້.",
"language" => "ພາສາ",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "ກະລຸນາກຳນົດລາຄາຕົ້ນທຶນ.",
"count" => "ອັບເດດປະລິມານສິນຄ້າໃນສາງ",
"csv_import_failed" => "CSV import failed",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "The uploaded file has no data or is formatted incorrectly.",
"csv_import_partially_failed" => "Item import successful with some failures:",
"csv_import_success" => "Item import successful.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "",
"error_adding_updating" => "",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "",
"error_updating_demo_admin" => "",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "",
"count" => "",
"csv_import_failed" => "",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "",
"csv_import_partially_failed" => "",
"csv_import_success" => "",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "",
"error_adding_updating" => "",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "",
"error_updating_demo_admin" => "",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "",
"count" => "",
"csv_import_failed" => "",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "",
"csv_import_partially_failed" => "",
"csv_import_success" => "",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Huidig paswoord is ongeldig.",
"employee" => "Werknemer",
"error_adding_updating" => "Fout bij het toevoegen/aanpassen medewerker.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Je kan de demo gebruilker niet verwijderen.",
"error_updating_demo_admin" => "Jij kan de demo gebruiker niet veranderen.",
"language" => "Taal",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Groothandelsprijs is een verplicht veld.",
"count" => "Update Stock",
"csv_import_failed" => "CSV import mislukt",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Het geüploade CSV-bestand bevat geen gegevens of is onjuist geformatteerd.",
"csv_import_partially_failed" => "Er waren {0} artikel import fout(en) op regel(s): {1}. Er werden geen rijen geïmporteerd.",
"csv_import_success" => "Artikel CSV import geslaagd.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Huidige wachtwoord is ongeldig.",
"employee" => "Werknemer",
"error_adding_updating" => "Werknemer toevoegen of bijwerken mislukt.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Kan de demo admin gebruiker niet verwijderen.",
"error_updating_demo_admin" => "Kan de demo admin gebruiker niet wijzigen.",
"language" => "Taal",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Inkoopprijs is een vereist veld.",
"count" => "Voorraad bijwerken",
"csv_import_failed" => "CSV importeren mislukt",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Het geüploade CSV-bestand bevat geen gegevens or heeft de verkeerde indeling.",
"csv_import_partially_failed" => "Er zijn {0} artikel import fout(en) in lijn(en): {1}. Geen rijen geïmporteerd.",
"csv_import_success" => "Artikel CSV geïmporteerd.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "",
"error_adding_updating" => "",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "",
"error_updating_demo_admin" => "",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "",
"count" => "",
"csv_import_failed" => "",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "",
"csv_import_partially_failed" => "",
"csv_import_success" => "",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Senha atual inválida.",
"employee" => "Funcionário",
"error_adding_updating" => "Erro ao adicionar/atualizar funcionário.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Você não pode excluir o usuário administrador de demonstração.",
"error_updating_demo_admin" => "Você não pode alterar o usuário de demonstração de administração.",
"language" => "Linguagem",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Preço de custo é um campo obrigatório.",
"count" => "Acrescentar ao Inventário",
"csv_import_failed" => "Importação do CSV falhou",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Seu arquivo enviado não contém dados ou formato errado.",
"csv_import_partially_failed" => "Houve {0} falha na importação de itens na(s) linha(s): {1}. Nenhuma linha foi importada.",
"csv_import_success" => "Importação de Itens com sucesso.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "",
"error_adding_updating" => "",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "",
"error_updating_demo_admin" => "",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "",
"count" => "",
"csv_import_failed" => "",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "",
"csv_import_partially_failed" => "",
"csv_import_success" => "",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Текущий пароль введен неверно.",
"employee" => "Сотрудник",
"error_adding_updating" => "Ошибка при добавлении/обновлении сотрудника.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Вы не можете удалить демо-администратора.",
"error_updating_demo_admin" => "Вы не можете изменить демо-администратора.",
"language" => "Язык",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Оптовая цена - обязательное поле.",
"count" => "Обновление запасов",
"csv_import_failed" => "Ошибка импорта CSV",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Загруженный файл CSV не содержит данных или имеет неправильный формат.",
"csv_import_partially_failed" => "В строке (строках) произошло {0} ошибок импорта: {1}. Ничего не было импортировано.",
"csv_import_success" => "Товар успешно импортирован из CSV.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Nuvarande lösenord är fel.",
"employee" => "Anställd",
"error_adding_updating" => "Anställd lägg till eller uppdatering misslyckades.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Du kan inte radera demo admin-användaren.",
"error_updating_demo_admin" => "Du kan inte ändra demo admin-användaren.",
"language" => "Språk",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Grossistpris är ett obligatoriskt fält.",
"count" => "Uppdatera Inventory",
"csv_import_failed" => "CSV-import misslyckades",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Den uppladdade filen har ingen data eller är formaterad felaktigt.",
"csv_import_partially_failed" => "Det fanns{0} importfel (er) på rad (er):{1}. Inga rader importerades.",
"csv_import_success" => "Artikelimporten lyckades.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Nenosiri la sasa si sahihi.",
"employee" => "Mfanyakazi",
"error_adding_updating" => "Kuongeza au kusasisha mfanyakazi kumeshindikana.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Huwezi kufuta mtumiaji wa admin wa majaribio.",
"error_updating_demo_admin" => "Huwezi kubadilisha mtumiaji wa admin wa majaribio.",
"language" => "Lugha",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Bei ya Jumla ni kiashiria kinachohitajika.",
"count" => "Sasisha Hisa",
"csv_import_failed" => "Uingizaji wa CSV umeshindikana",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Faili ya CSV iliyopakiwa haina data au imepangwa vibaya.",
"csv_import_partially_failed" => "Kumekuwa na makosa {0} ya uingizaji wa bidhaa kwenye mstari: {1}. Hakuna safu zilizoingizwa.",
"csv_import_success" => "Uingizaji wa Bidhaa kutoka CSV umefanikiwa.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Nenosiri la sasa si sahihi.",
"employee" => "Mfanyakazi",
"error_adding_updating" => "Kuongeza au kusasisha mfanyakazi kumeshindikana.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Huwezi kufuta mtumiaji wa admin wa majaribio.",
"error_updating_demo_admin" => "Huwezi kubadilisha mtumiaji wa admin wa majaribio.",
"language" => "Lugha",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Bei ya Jumla ni kiashiria kinachohitajika.",
"count" => "Sasisha Hisa",
"csv_import_failed" => "Uingizaji wa CSV umeshindikana",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Faili ya CSV iliyopakiwa haina data au imepangwa vibaya.",
"csv_import_partially_failed" => "Kumekuwa na makosa {0} ya uingizaji wa bidhaa kwenye mstari: {1}. Hakuna safu zilizoingizwa.",
"csv_import_success" => "Uingizaji wa Bidhaa kutoka CSV umefanikiwa.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Current Password is invalid.",
"employee" => "Employee",
"error_adding_updating" => "Employee add or update failed.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "You can not delete the demo admin user.",
"error_updating_demo_admin" => "You can not change the demo admin user.",
"language" => "Language",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Wholesale Price is a required field.",
"count" => "Update Inventory",
"csv_import_failed" => "CSV import failed",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "The uploaded CSV file has no data or is formatted incorrectly.",
"csv_import_partially_failed" => "There were {0} item import failure(s) on line(s): {1}. No rows were imported.",
"csv_import_success" => "Item CSV import successful.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "รหัสผ่านปัจจุบันไม่ถูกต้อง",
"employee" => "พนักงาน",
"error_adding_updating" => "การเพิ่มหรือปรับปรุงข้อมูลพนักงานผิดพลาด",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "คุณไม่สามารถลบผู้ใช้งานสำหรับการเดโม้ได้",
"error_updating_demo_admin" => "คุณไม่สามารถทำการเปลี่ยนข้อมูลผู้ใช้งานเดโม้ได้",
"language" => "ภาษา",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "ต้องกรอกราคาขายส่ง",
"count" => "แก้ไขจำนวนสินค้าคงคลัง",
"csv_import_failed" => "นำเข้าข้อมูล CSV ล้มเหลว",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Your uploaded file has no data or wrong format",
"csv_import_partially_failed" => "มีรายการ {0} รายการที่นำเข้าล้มเหลว : {1} รายการที่ยังไม่ได้นำเข้า",
"csv_import_success" => "Import of Items successful",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Current Password is invalid.",
"employee" => "Employee",
"error_adding_updating" => "Employee add or update failed.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "You can not change the demo admin user.",
"error_updating_demo_admin" => "You can not delete the demo admin user.",
"language" => "Language",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Purchase Price is a required field.",
"count" => "Update Inventory",
"csv_import_failed" => "CSV import failed",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "The uploaded file has no data or is formatted incorrectly.",
"csv_import_partially_failed" => "Customer import successful with some failures:",
"csv_import_success" => "Item import successful.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Var Olan Parola geçersiz.",
"employee" => "Personel",
"error_adding_updating" => "Personel ekleme/güncelleme hatası.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Admin güncellenemez.",
"error_updating_demo_admin" => "Admin silinemez.",
"language" => "Dil",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Toptan Fiyatı zorunlu alandır.",
"count" => "Stoğu Güncelle",
"csv_import_failed" => "CSV aktarım hatası",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Yüklenen dosya herhangi bir veri içermiyor veya hatalı formatta.",
"csv_import_partially_failed" => "Bazı ürünler aktarılamadı. Aktarılamayanlar listesi.",
"csv_import_success" => "Ürün aktarımı başarılı.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Поточний пароль невірний.",
"employee" => "Працівник",
"error_adding_updating" => "Помилка при додаванні/оновлені працівника.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Ви не можете видалити аккаунт користувача.",
"error_updating_demo_admin" => "Ви не можете змінити аккаунт користувача.",
"language" => "Мова",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Оптова ціна - обов'язкове поле.",
"count" => "Оновити інвентар",
"csv_import_failed" => "Помилка імпорту CSV",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Завандажений файл порожній або відформатований неправильно.",
"csv_import_partially_failed" => "У рядках виявлено {0} помилки імпортування елементів: {1}. Не було імпортовано жодних рядків.",
"csv_import_success" => "Імпорт товару CSV успішний.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "",
"error_adding_updating" => "",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "",
"error_updating_demo_admin" => "",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "",
"count" => "",
"csv_import_failed" => "",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "",
"csv_import_partially_failed" => "",
"csv_import_success" => "",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "Mật khẩu hiện tại không hợp lệ.",
"employee" => "Nhân viên",
"error_adding_updating" => "Gặp lỗi khi cập nhật hay thêm nhân viên.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "Bạn không thể xóa người dùng demo admin.",
"error_updating_demo_admin" => "Bạn không thể thay đổi người dùng demo admin.",
"language" => "Ngôn ngữ",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "Trường Giá bán buôn là bắt buộc.",
"count" => "Cập hàng tồn kho",
"csv_import_failed" => "Gặp lỗi khi nhập từ CSV",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Tập tin tải lên không có dữ liệu hoặc là nó có định dạng không đúng.",
"csv_import_partially_failed" => "{0} mục tin gặp lỗi khi nhập trên dòng: {1}. Không có hàng nào được nhập vào.",
"csv_import_success" => "Nhập hàng hóa thành công.",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "",
"employee" => "員工",
"error_adding_updating" => "添加/更新員工錯誤",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "您不能刪除admin用戶",
"error_updating_demo_admin" => "您不能更改admin用戶",
"language" => "",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "成本價為必填攔位",
"count" => "更新庫存",
"csv_import_failed" => "CSV匯入失敗",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "Your uploaded file has no data or wrong format",
"csv_import_partially_failed" => "Most Items imported. But some were not, here is the list",
"csv_import_success" => "Import of Items successful",

View File

@@ -14,6 +14,8 @@ return [
"current_password_invalid" => "當前密碼無效。",
"employee" => "員工",
"error_adding_updating" => "添加/更新員工錯誤.",
"error_deleting_admin" => "",
"error_updating_admin" => "",
"error_deleting_demo_admin" => "您不能刪除admin用戶.",
"error_updating_demo_admin" => "您不能更改admin用戶.",
"language" => "語言",

View File

@@ -26,6 +26,7 @@ return [
"cost_price_required" => "成本價為必填攔位.",
"count" => "更新庫存",
"csv_import_failed" => "CSV匯入失敗",
"csv_import_invalid_location" => "",
"csv_import_nodata_wrongformat" => "上傳的 CSV 文件沒有數據或格式不正確。",
"csv_import_partially_failed" => "線上有 {0} 個項目導入失敗:{1}。未導入任何行。",
"csv_import_success" => "項目 CSV 導入成功。",

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Libraries;
use App\Enums\RewardOperation;
use App\Models\Customer;
/**
* Reward library
*
* Handles customer reward points business logic for sales transactions.
* Extracted from Sale model to provide centralized reward management.
*/
class Reward_lib
{
private Customer $customer;
public function __construct()
{
$this->customer = model(Customer::class);
}
/**
* Calculates reward points earned for a purchase.
*
* @param float $totalAmount Total sale amount
* @param float $pointsPercent Points percentage from customer reward package
* @return float Points earned
*/
public function calculatePointsEarned(float $totalAmount, float $pointsPercent): float
{
return $totalAmount * $pointsPercent / 100;
}
/**
* Adjusts customer reward points for a sale transaction.
* Handles new sales, sale updates, and sale cancellations.
* Prevents negative balances by validating sufficient points before deduction.
*
* @param int|null $customerId Customer ID (null for walk-in customers)
* @param float $rewardAmount Amount to deduct from points (for new/updated sales)
* @param RewardOperation $operation Operation type (Deduct, Restore, or Adjust)
* @return bool Success status (false if insufficient points for deduct/adjust)
*/
public function adjustRewardPoints(?int $customerId, float $rewardAmount, RewardOperation $operation): bool
{
if (empty($customerId) || $rewardAmount == 0) {
return false;
}
$currentPoints = $this->customer->get_info($customerId)->points ?? 0;
switch ($operation) {
case RewardOperation::Deduct:
case RewardOperation::Adjust:
if ($currentPoints < $rewardAmount) {
log_message(
'warning',
'Reward_lib::adjustRewardPoints insufficient points customer_id=' . $customerId
. ' current=' . $currentPoints . ' requested=' . $rewardAmount
);
return false;
}
$this->customer->update_reward_points_value($customerId, $currentPoints - $rewardAmount);
return true;
case RewardOperation::Restore:
$this->customer->update_reward_points_value($customerId, $currentPoints + $rewardAmount);
return true;
default:
return false;
}
}
/**
* Handles reward point adjustment when customer changes on a sale.
* Restores points to previous customer, deducts from new customer.
* Prevents negative balances by capping deduction at available points.
*
* @param int|null $previousCustomerId Previous customer ID
* @param int|null $newCustomerId New customer ID
* @param float $previousRewardUsed Reward points used by previous customer
* @param float $newRewardUsed Reward points to be used by new customer
* @return array ['restored' => float, 'charged' => float, 'insufficient' => bool] Amounts restored/charged
*/
public function handleCustomerChange(?int $previousCustomerId, ?int $newCustomerId, float $previousRewardUsed, float $newRewardUsed): array
{
$result = ['restored' => 0.0, 'charged' => 0.0, 'insufficient' => false];
if ($previousCustomerId === $newCustomerId) {
return $result;
}
if (!empty($previousCustomerId) && $previousRewardUsed != 0) {
$previousPoints = $this->customer->get_info($previousCustomerId)->points ?? 0;
$this->customer->update_reward_points_value($previousCustomerId, $previousPoints + $previousRewardUsed);
$result['restored'] = $previousRewardUsed;
}
if (!empty($newCustomerId) && $newRewardUsed != 0) {
$newPoints = $this->customer->get_info($newCustomerId)->points ?? 0;
if ($newPoints < $newRewardUsed) {
log_message(
'warning',
'Reward_lib::handleCustomerChange insufficient points new_customer_id=' . $newCustomerId
. ' available=' . $newPoints . ' requested=' . $newRewardUsed
);
$result['insufficient'] = true;
}
$actualCharged = min($newPoints, $newRewardUsed);
$this->customer->update_reward_points_value($newCustomerId, max(0, $newPoints - $newRewardUsed));
$result['charged'] = $actualCharged;
}
return $result;
}
/**
* Adjusts reward points delta for same customer (e.g., payment amount changed).
* Prevents negative balances by validating sufficient points before adjustment.
*
* @param int|null $customerId Customer ID
* @param float $rewardAdjustment Difference between new and previous reward usage (positive = more used)
* @return bool Success status (false if insufficient points)
*/
public function adjustRewardDelta(?int $customerId, float $rewardAdjustment): bool
{
if (empty($customerId) || $rewardAdjustment == 0) {
return false;
}
$currentPoints = $this->customer->get_info($customerId)->points ?? 0;
if ($rewardAdjustment > 0 && $currentPoints < $rewardAdjustment) {
log_message(
'warning',
'Reward_lib::adjustRewardDelta insufficient points customer_id=' . $customerId
. ' current=' . $currentPoints . ' adjustment=' . $rewardAdjustment
);
return false;
}
$this->customer->update_reward_points_value($customerId, $currentPoints - $rewardAdjustment);
return true;
}
/**
* Validates if a customer has sufficient reward points for a purchase.
*
* @param int $customerId Customer ID
* @param float $requiredPoints Points required for purchase
* @return bool True if customer has sufficient points
*/
public function hasSufficientPoints(int $customerId, float $requiredPoints): bool
{
$currentPoints = $this->customer->get_info($customerId)->points ?? 0;
return $currentPoints >= $requiredPoints;
}
/**
* Gets current reward points for a customer.
*
* @param int $customerId Customer ID
* @return float Current points balance
*/
public function getPointsBalance(int $customerId): float
{
return $this->customer->get_info($customerId)->points ?? 0;
}
/**
* Calculates reward payment amount from a payments array.
*
* @param array $payments Array of payment data
* @param array $rewardLabels Array of valid reward payment labels (localized)
* @return float Total reward payment amount
*/
public function calculateRewardPaymentAmount(array $payments, array $rewardLabels): float
{
$totalRewardAmount = 0;
foreach ($payments as $payment) {
if (in_array($payment['payment_type'] ?? '', $rewardLabels, true)) {
$totalRewardAmount += floatval($payment['payment_amount'] ?? 0);
}
}
return $totalRewardAmount;
}
}

View File

@@ -16,6 +16,18 @@ use stdClass;
*/
class Item extends Model
{
public const ALLOWED_BULK_EDIT_FIELDS = [
'name',
'category',
'supplier_id',
'cost_price',
'unit_price',
'reorder_level',
'description',
'allow_alt_description',
'is_serialized'
];
protected $table = 'items';
protected $primaryKey = 'item_id';
protected $useAutoIncrement = true;
@@ -199,9 +211,9 @@ class Item extends Model
if (!empty($search)) {
if ($attributes_enabled && $filters['search_custom']) {
$builder->having("attribute_values LIKE :search:", ['search' => "%$search%"]);
$builder->orHaving("attribute_dtvalues LIKE :search_dt:", ['search_dt' => "%$search%"]);
$builder->orHaving("attribute_dvalues LIKE :search_d:", ['search_d' => "%$search%"]);
$builder->havingLike('attribute_values', $search);
$builder->orHavingLike('attribute_dtvalues', $search);
$builder->orHavingLike('attribute_dvalues', $search);
} else {
$builder->groupStart();
$builder->like('name', $search);

View File

@@ -6,6 +6,7 @@ use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\ResultInterface;
use CodeIgniter\Model;
use App\Libraries\Sale_lib;
use App\Libraries\Reward_lib;
use Config\OSPOS;
use ReflectionException;
@@ -436,63 +437,165 @@ class Sale extends Model
*/
public function update($sale_id = null, $sale_data = null): bool
{
$previousCustomerRow = $this->db->table('sales')
->select('customer_id')
->where('sale_id', $sale_id)
->get()
->getRow();
$previousCustomerId = $previousCustomerRow ? $previousCustomerRow->customer_id : null;
log_message(
'debug',
'Sale::update start sale_id=' . $sale_id . ' previous_customer_id=' . ($previousCustomerId ?? 'null')
);
$updateData = $sale_data;
unset($updateData['payments']);
$newCustomerId = array_key_exists('customer_id', $updateData)
? $updateData['customer_id']
: $previousCustomerId;
$customer = model(Customer::class);
$currentPayments = $this->get_sale_payments($sale_id)->getResultArray();
$currentRewardUsed = 0;
foreach ($currentPayments as $payment) {
if ($this->isRewardPayment($payment['payment_type'])) {
$currentRewardUsed += $payment['payment_amount'];
}
}
log_message(
'debug',
'Sale::update current rewards sale_id=' . $sale_id
. ' current_reward_used=' . $currentRewardUsed
. ' payment_count=' . count($currentPayments)
);
$newRewardUsed = 0;
if (!empty($sale_data['payments'])) {
foreach ($sale_data['payments'] as $payment) {
if ($this->isRewardPayment($payment['payment_type'])) {
$newRewardUsed += $payment['payment_amount'];
}
}
} else {
$newRewardUsed = $currentRewardUsed;
}
log_message(
'debug',
'Sale::update new rewards sale_id=' . $sale_id
. ' new_reward_used=' . $newRewardUsed
. ' payment_count=' . count($sale_data['payments'] ?? [])
);
$this->db->transStart();
$builder = $this->db->table('sales');
$builder->where('sale_id', $sale_id);
$update_data = $sale_data;
unset($update_data['payments']);
$success = $builder->update($update_data);
$success = $builder->update($updateData);
// Touch payment only if update sale is successful and there is a payments object otherwise the result would be to delete all the payments associated to the sale
if ($success && !empty($sale_data['payments'])) {
// Run these queries as a transaction, we want to make sure we do all or nothing
$this->db->transStart();
$builder = $this->db->table('sales_payments');
// Add new payments
foreach ($sale_data['payments'] as $payment) {
$payment_id = $payment['payment_id'];
$payment_type = $payment['payment_type'];
$payment_amount = $payment['payment_amount'];
$cash_refund = $payment['cash_refund'];
$cash_adjustment = $payment['cash_adjustment'];
$employee_id = $payment['employee_id'];
$paymentId = $payment['payment_id'];
$paymentType = $payment['payment_type'];
$paymentAmount = $payment['payment_amount'];
$cashRefund = $payment['cash_refund'];
$cashAdjustment = $payment['cash_adjustment'];
$employeeId = $payment['employee_id'];
if ($payment_id == NEW_ENTRY && $payment_amount != 0) {
// Add a new payment transaction
$sales_payments_data = [
if ($paymentId == NEW_ENTRY && $paymentAmount != 0) {
$salesPaymentsData = [
'sale_id' => $sale_id,
'payment_type' => $payment_type,
'payment_amount' => $payment_amount,
'cash_refund' => $cash_refund,
'cash_adjustment' => $cash_adjustment,
'employee_id' => $employee_id
'payment_type' => $paymentType,
'payment_amount' => $paymentAmount,
'cash_refund' => $cashRefund,
'cash_adjustment' => $cashAdjustment,
'employee_id' => $employeeId
];
$success = $builder->insert($sales_payments_data);
} elseif ($payment_id != NEW_ENTRY) {
if ($payment_amount != 0) {
// Update existing payment transactions (payment_type only)
$sales_payments_data = [
'payment_type' => $payment_type,
'payment_amount' => $payment_amount,
'cash_refund' => $cash_refund,
'cash_adjustment' => $cash_adjustment
$success = $builder->insert($salesPaymentsData);
} elseif ($paymentId != NEW_ENTRY) {
if ($paymentAmount != 0) {
$salesPaymentsData = [
'payment_type' => $paymentType,
'payment_amount' => $paymentAmount,
'cash_refund' => $cashRefund,
'cash_adjustment' => $cashAdjustment
];
$builder->where('payment_id', $payment_id);
$success = $builder->update($sales_payments_data);
$builder->where('payment_id', $paymentId);
$success = $builder->update($salesPaymentsData);
} else {
// Remove existing payment transactions with a payment amount of zero
$success = $builder->delete(['payment_id' => $payment_id]);
$success = $builder->delete(['payment_id' => $paymentId]);
}
}
}
$this->db->transComplete();
$success &= $this->db->transStatus();
}
return $success;
if ($success) {
log_message(
'debug',
'Sale::update reward adjust sale_id=' . $sale_id
. ' previous_customer_id=' . ($previousCustomerId ?? 'null')
. ' new_customer_id=' . ($newCustomerId ?? 'null')
. ' current_reward_used=' . $currentRewardUsed
. ' new_reward_used=' . $newRewardUsed
);
if ($previousCustomerId != $newCustomerId) {
if (!empty($previousCustomerId) && $currentRewardUsed != 0) {
$previousPoints = $customer->get_info($previousCustomerId)->points ?? 0;
$customer->update_reward_points_value($previousCustomerId, $previousPoints + $currentRewardUsed);
log_message(
'debug',
'Sale::update reward restore previous_customer_id=' . $previousCustomerId
. ' previous_points=' . $previousPoints
. ' restored=' . $currentRewardUsed
);
}
if (!empty($newCustomerId) && $newRewardUsed != 0) {
$newPoints = $customer->get_info($newCustomerId)->points ?? 0;
if ($newPoints < $newRewardUsed) {
log_message(
'warning',
'Sale::update insufficient points new_customer_id=' . $newCustomerId
. ' available=' . $newPoints . ' requested=' . $newRewardUsed
);
}
$customer->update_reward_points_value($newCustomerId, max(0, $newPoints - $newRewardUsed));
log_message(
'debug',
'Sale::update reward charge new_customer_id=' . $newCustomerId
. ' new_points=' . $newPoints
. ' charged=' . $newRewardUsed
);
}
} else {
$rewardAdjustment = $newRewardUsed - $currentRewardUsed;
if ($rewardAdjustment != 0 && !empty($newCustomerId)) {
$currentPoints = $customer->get_info($newCustomerId)->points ?? 0;
if ($rewardAdjustment > 0 && $currentPoints < $rewardAdjustment) {
log_message(
'warning',
'Sale::update insufficient points customer_id=' . $newCustomerId
. ' current=' . $currentPoints . ' adjustment=' . $rewardAdjustment
);
}
$customer->update_reward_points_value($newCustomerId, $currentPoints - $rewardAdjustment);
log_message(
'debug',
'Sale::update reward delta new_customer_id=' . $newCustomerId
. ' current_points=' . $currentPoints
. ' reward_adjustment=' . $rewardAdjustment
);
}
}
}
$this->db->transComplete();
return $success && $this->db->transStatus();
}
/**
@@ -532,7 +635,7 @@ class Sale extends Model
return -1; // TODO: Replace -1 with a constant
}
$sales_data = [
$salesData = [
'sale_time' => date('Y-m-d H:i:s'),
'customer_id' => $customer->exists($customer_id) ? $customer_id : null,
'employee_id' => $employee_id,
@@ -545,35 +648,30 @@ class Sale extends Model
'sale_type' => $sale_type
];
// Run these queries as a transaction, we want to make sure we do all or nothing
$this->db->transStart();
if ($sale_id == NEW_ENTRY) {
$builder = $this->db->table('sales');
$builder->insert($sales_data);
$builder->insert($salesData);
$sale_id = $this->db->insertID();
} else {
$builder = $this->db->table('sales');
$builder->where('sale_id', $sale_id);
$builder->update($sales_data);
$builder->update($salesData);
}
$total_amount = 0;
$total_amount_used = 0;
$totalAmount = 0;
$totalAmountUsed = 0;
foreach ($payments as $payment_id => $payment) {
if (!empty(strstr($payment['payment_type'], lang('Sales.giftcard')))) {
// We have a gift card, and we have to deduct the used value from the total value of the card.
$splitpayment = explode(':', $payment['payment_type']); // TODO: this variable doesn't follow our naming conventions. Probably should be refactored to split_payment.
$cur_giftcard_value = $giftcard->get_giftcard_value($splitpayment[1]); // TODO: this should be refactored to $current_giftcard_value
$giftcard->update_giftcard_value($splitpayment[1], $cur_giftcard_value - $payment['payment_amount']);
} elseif (!empty(strstr($payment['payment_type'], lang('Sales.rewards')))) {
$cur_rewards_value = $customer->get_info($customer_id)->points;
$customer->update_reward_points_value($customer_id, $cur_rewards_value - $payment['payment_amount']);
$total_amount_used = floatval($total_amount_used) + floatval($payment['payment_amount']);
}
foreach ($payments as $payment) {
$totalAmountUsed += $this->processPaymentType(
$payment,
$customer_id,
$customer,
$giftcard
);
$sales_payments_data = [
$salesPaymentsData = [
'sale_id' => $sale_id,
'payment_type' => $payment['payment_type'],
'payment_amount' => $payment['payment_amount'],
@@ -583,74 +681,71 @@ class Sale extends Model
];
$builder = $this->db->table('sales_payments');
$builder->insert($sales_payments_data);
$builder->insert($salesPaymentsData);
$total_amount = floatval($total_amount) + floatval($payment['payment_amount']) - floatval($payment['cash_refund']);
$totalAmount = floatval($totalAmount) + floatval($payment['payment_amount']) - floatval($payment['cash_refund']);
}
$this->save_customer_rewards($customer_id, $sale_id, $total_amount, $total_amount_used);
$this->save_customer_rewards($customer_id, $sale_id, $totalAmount, $totalAmountUsed);
$customer = $customer->get_info($customer_id);
foreach ($items as $line => $item_data) {
$cur_item_info = $item->get_info($item_data['item_id']);
foreach ($items as $itemData) {
$currentItemInfo = $item->get_info($itemData['item_id']);
if ($item_data['price'] == 0.00) {
$item_data['discount'] = 0.00;
if ($itemData['price'] == 0.00) {
$itemData['discount'] = 0.00;
}
$sales_items_data = [
$salesItemsData = [
'sale_id' => $sale_id,
'item_id' => $item_data['item_id'],
'line' => $item_data['line'],
'description' => character_limiter($item_data['description'], 255),
'serialnumber' => character_limiter($item_data['serialnumber'], 30),
'quantity_purchased' => $item_data['quantity'],
'discount' => $item_data['discount'],
'discount_type' => $item_data['discount_type'],
'item_cost_price' => $item_data['cost_price'],
'item_unit_price' => $item_data['price'],
'item_location' => $item_data['item_location'],
'print_option' => $item_data['print_option']
'item_id' => $itemData['item_id'],
'line' => $itemData['line'],
'description' => character_limiter($itemData['description'], 255),
'serialnumber' => character_limiter($itemData['serialnumber'], 30),
'quantity_purchased' => $itemData['quantity'],
'discount' => $itemData['discount'],
'discount_type' => $itemData['discount_type'],
'item_cost_price' => $itemData['cost_price'],
'item_unit_price' => $itemData['price'],
'item_location' => $itemData['item_location'],
'print_option' => $itemData['print_option']
];
$builder = $this->db->table('sales_items');
$builder->insert($sales_items_data);
$builder->insert($salesItemsData);
if ($cur_item_info->stock_type == HAS_STOCK && $sale_status == COMPLETED) { // TODO: === ?
// Update stock quantity if item type is a standard stock item and the sale is a standard sale
$item_quantity_data = $item_quantity->get_item_quantity($item_data['item_id'], $item_data['item_location']);
if ($currentItemInfo->stock_type == HAS_STOCK && $sale_status == COMPLETED) {
$itemQuantityData = $item_quantity->get_item_quantity($itemData['item_id'], $itemData['item_location']);
$item_quantity->save_value(
[
'quantity' => $item_quantity_data->quantity - $item_data['quantity'],
'item_id' => $item_data['item_id'],
'location_id' => $item_data['item_location']
'quantity' => $itemQuantityData->quantity - $itemData['quantity'],
'item_id' => $itemData['item_id'],
'location_id' => $itemData['item_location']
],
$item_data['item_id'],
$item_data['item_location']
$itemData['item_id'],
$itemData['item_location']
);
// If an items was deleted but later returned it's restored with this rule
if ($item_data['quantity'] < 0) {
$item->undelete($item_data['item_id']);
if ($itemData['quantity'] < 0) {
$item->undelete($itemData['item_id']);
}
// Inventory Count Details
$sale_remarks = 'POS ' . $sale_id; // TODO: Use string interpolation here.
$inv_data = [
$saleRemarks = 'POS ' . $sale_id;
$inventoryData = [
'trans_date' => date('Y-m-d H:i:s'),
'trans_items' => $item_data['item_id'],
'trans_items' => $itemData['item_id'],
'trans_user' => $employee_id,
'trans_location' => $item_data['item_location'],
'trans_comment' => $sale_remarks,
'trans_inventory' => -$item_data['quantity']
'trans_location' => $itemData['item_location'],
'trans_comment' => $saleRemarks,
'trans_inventory' => -$itemData['quantity']
];
$inventory->insert($inv_data, false);
$inventory->insert($inventoryData, false);
}
$attribute->copy_attribute_links($item_data['item_id'], 'sale_id', $sale_id);
$attribute->copy_attribute_links($itemData['item_id'], 'sale_id', $sale_id);
}
if ($customer_id == NEW_ENTRY || $customer->taxable) {
@@ -779,45 +874,71 @@ class Sale extends Model
*/
public function delete($sale_id = null, bool $purge = false, bool $update_inventory = true, $employee_id = null): bool
{
// Start a transaction to assure data integrity
$this->db->transStart();
$sale_status = $this->get_sale_status($sale_id);
if ($update_inventory && $sale_status == COMPLETED) {
// Defect, not all item deletions will be undone?
// Get array with all the items involved in the sale to update the inventory tracking
$inventory = model('Inventory');
$item = model(Item::class);
$item_quantity = model(Item_quantity::class);
$itemQuantity = model(Item_quantity::class);
$items = $this->get_sale_items($sale_id)->getResultArray();
foreach ($items as $item_data) {
$cur_item_info = $item->get_info($item_data['item_id']);
foreach ($items as $itemData) {
$currentItemInfo = $item->get_info($itemData['item_id']);
if ($cur_item_info->stock_type == HAS_STOCK) {
// Create query to update inventory tracking
$inv_data = [
if ($currentItemInfo->stock_type == HAS_STOCK) {
$inventoryData = [
'trans_date' => date('Y-m-d H:i:s'),
'trans_items' => $item_data['item_id'],
'trans_items' => $itemData['item_id'],
'trans_user' => $employee_id,
'trans_comment' => 'Deleting sale ' . $sale_id,
'trans_location' => $item_data['item_location'],
'trans_inventory' => $item_data['quantity_purchased']
'trans_location' => $itemData['item_location'],
'trans_inventory' => $itemData['quantity_purchased']
];
// Update inventory
$inventory->insert($inv_data, false);
$inventory->insert($inventoryData, false);
// Update quantities
$item_quantity->change_quantity($item_data['item_id'], $item_data['item_location'], $item_data['quantity_purchased']);
$itemQuantity->change_quantity($itemData['item_id'], $itemData['item_location'], $itemData['quantity_purchased']);
}
}
}
if ($sale_status !== CANCELED) {
$payments = $this->get_sale_payments($sale_id)->getResultArray();
$rewardUsed = 0;
foreach ($payments as $payment) {
if ($this->isRewardPayment($payment['payment_type'])) {
$rewardUsed += $payment['payment_amount'];
}
}
log_message(
'debug',
'Sale::delete reward usage sale_id=' . $sale_id
. ' reward_used=' . $rewardUsed
. ' payment_count=' . count($payments)
);
if ($rewardUsed > 0) {
$customerObj = $this->get_customer($sale_id);
if (empty($customerObj) || empty($customerObj->person_id)) {
log_message('error', 'Sale::delete cannot restore rewards - no customer for sale_id=' . $sale_id);
} else {
$customerId = $customerObj->person_id;
$customer = model(Customer::class);
$currentPoints = $customer->get_info($customerId)->points ?? 0;
$customer->update_reward_points_value($customerId, $currentPoints + $rewardUsed);
log_message(
'debug',
'Sale::delete reward restore customer_id=' . $customerId
. ' current_points=' . $currentPoints
. ' restored=' . $rewardUsed
);
}
}
}
$this->update_sale_status($sale_id, CANCELED);
// Execute transaction
$this->db->transComplete();
return $this->db->transStatus();
@@ -1387,6 +1508,94 @@ class Sale extends Model
}
}
/**
* Determines if the payment type represents a rewards payment across locales.
*/
private function isRewardPayment(string $payment_type): bool
{
if ($payment_type === '') {
return false;
}
foreach ($this->getRewardPaymentLabels() as $label) {
if ($payment_type === $label) {
return true;
}
}
return false;
}
/**
* Returns unique localized labels for the rewards payment type.
*/
private function getRewardPaymentLabels(): array
{
static $labels = null;
if ($labels !== null) {
return $labels;
}
$labels = [lang('Sales.rewards')];
$languagePaths = glob(APPPATH . 'Language/*/Sales.php');
if (!empty($languagePaths)) {
foreach ($languagePaths as $salesFile) {
if (!is_file($salesFile)) {
continue;
}
$translations = require $salesFile;
if (is_array($translations) && !empty($translations['rewards'])) {
$labels[] = $translations['rewards'];
}
}
}
$labels = array_map('trim', $labels);
$labels = array_filter($labels, static fn($label) => $label !== '');
$labels = array_values(array_unique($labels));
return $labels;
}
/**
* Processes payment type for giftcard and reward deductions during sale creation.
* Returns the amount used for rewards (0 for giftcards).
*/
private function processPaymentType(array $payment, int $customerId, object $customer, object $giftcard): float
{
$paymentType = $payment['payment_type'];
$paymentAmount = $payment['payment_amount'];
if (!empty(strstr($paymentType, lang('Sales.giftcard')))) {
$splitPayment = explode(':', $paymentType);
if (count($splitPayment) < 2 || empty($splitPayment[1])) {
log_message('error', 'Sale::processPaymentType invalid giftcard format: ' . $paymentType);
return 0;
}
$giftcardNumber = $splitPayment[1];
$currentGiftcardValue = $giftcard->get_giftcard_value($giftcardNumber);
$giftcard->update_giftcard_value($giftcardNumber, $currentGiftcardValue - $paymentAmount);
return 0;
}
if ($this->isRewardPayment($paymentType)) {
$currentRewardsValue = $customer->get_info($customerId)->points ?? 0;
if ($currentRewardsValue < $paymentAmount) {
log_message(
'warning',
'Sale::processPaymentType insufficient points customer_id=' . $customerId
. ' available=' . $currentRewardsValue . ' requested=' . $paymentAmount
);
}
$customer->update_reward_points_value($customerId, max(0, $currentRewardsValue - $paymentAmount));
return floatval($paymentAmount);
}
return 0;
}
/**
* Creates a temporary table to store the sales_payments data
*

View File

@@ -133,7 +133,20 @@
var result = {};
$("[name*='attribute_links'").each(function() {
var definition_id = $(this).data('definition-id');
result[definition_id] = $(this).val();
var element = $(this);
// For checkboxes, use the visible checkbox, not the hidden input
if (element.attr('type') === 'hidden' && element.siblings('input[type="checkbox"]').length > 0) {
// Skip hidden inputs that have a corresponding checkbox
return;
}
// For checkboxes, get the checked state
if (element.attr('type') === 'checkbox') {
result[definition_id] = element.prop('checked') ? '1' : '0';
} else {
result[definition_id] = element.val();
}
});
return result;
};

View File

@@ -89,7 +89,7 @@
<?php if (!is_right_side_currency_symbol()): ?>
<span class="input-group-addon input-sm"><b><?= esc($config['currency_symbol']) ?></b></span>
<?php endif; ?>
<?= form_input(['name' => "payment_amount_$i", 'value' => $row->payment_amount, 'id' => "payment_amount_$i", 'class' => 'form-control input-sm', 'readonly' => 'true']) // TODO: add type attribute ?>
<?= form_input(['name' => "payment_amount_$i", 'value' => to_currency_no_money($row->payment_amount), 'id' => "payment_amount_$i", 'class' => 'form-control input-sm', 'readonly' => 'true']) // TODO: add type attribute ?>
<?php if (is_right_side_currency_symbol()): ?>
<span class="input-group-addon input-sm"><b><?= esc($config['currency_symbol']) ?></b></span>
<?php endif; ?>
@@ -112,7 +112,7 @@
<?php if (!is_right_side_currency_symbol()): ?>
<span class="input-group-addon input-sm"><b><?= esc($config['currency_symbol']) ?></b></span>
<?php endif; ?>
<?= form_input(['name' => "refund_amount_$i", 'value' => $row->cash_refund, 'id' => "refund_amount_$i", 'class' => 'form-control input-sm', 'readonly' => 'true']) ?>
<?= form_input(['name' => "refund_amount_$i", 'value' => to_currency_no_money($row->cash_refund), 'id' => "refund_amount_$i", 'class' => 'form-control input-sm', 'readonly' => 'true']) ?>
<?php if (is_right_side_currency_symbol()): ?>
<span class="input-group-addon input-sm"><b><?= esc($config['currency_symbol']) ?></b></span>
<?php endif; ?>

10
package-lock.json generated
View File

@@ -38,7 +38,7 @@
"jquery-form": "^4.3.0",
"jquery-ui-dist": "^1.12.1",
"jquery-validation": "^1.19.5",
"jspdf": "^4.1.0",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"tableexport.jquery.plugin": "^1.30.0"
},
@@ -3731,12 +3731,12 @@
"license": "MIT"
},
"node_modules/jspdf": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz",
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},

View File

@@ -59,7 +59,7 @@
"jquery-form": "^4.3.0",
"jquery-ui-dist": "^1.12.1",
"jquery-validation": "^1.19.5",
"jspdf": "^4.1.0",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"tableexport.jquery.plugin": "^1.30.0"
},

View File

@@ -0,0 +1,440 @@
<?php
namespace Tests\Libraries;
use CodeIgniter\Test\CIUnitTestCase;
use App\Enums\RewardOperation;
use App\Libraries\Reward_lib;
use App\Models\Customer;
class Reward_libTest extends CIUnitTestCase
{
use \CodeIgniter\Test\DatabaseTestTrait;
protected $migrate = true;
protected $migrateOnce = true;
protected $refresh = true;
protected $namespace = null;
private Reward_lib $rewardLib;
protected function setUp(): void
{
parent::setUp();
$this->rewardLib = new Reward_lib();
}
/**
* Test calculatePointsEarned returns correct calculation
*/
public function testCalculatePointsEarnedReturnsCorrectValue(): void
{
$pointsEarned = $this->rewardLib->calculatePointsEarned(100.00, 10);
$this->assertEquals(10.0, $pointsEarned);
}
/**
* Test calculatePointsEarned with zero amount
*/
public function testCalculatePointsEarnedWithZeroAmount(): void
{
$pointsEarned = $this->rewardLib->calculatePointsEarned(0, 10);
$this->assertEquals(0.0, $pointsEarned);
}
/**
* Test calculatePointsEarned with zero percentage
*/
public function testCalculatePointsEarnedWithZeroPercentage(): void
{
$pointsEarned = $this->rewardLib->calculatePointsEarned(100.00, 0);
$this->assertEquals(0.0, $pointsEarned);
}
/**
* Test calculatePointsEarned with percentage over 100
*/
public function testCalculatePointsEarnedWithHighPercentage(): void
{
$pointsEarned = $this->rewardLib->calculatePointsEarned(50.00, 200);
$this->assertEquals(100.0, $pointsEarned);
}
/**
* Test hasSufficientPoints returns true when customer has enough points
*/
public function testHasSufficientPointsReturnsTrueWhenSufficient(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 100]);
// Use reflection to inject mock
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->assertTrue($this->rewardLib->hasSufficientPoints(1, 50));
}
/**
* Test hasSufficientPoints returns false when customer has insufficient points
*/
public function testHasSufficientPointsReturnsFalseWhenInsufficient(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 30]);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->assertFalse($this->rewardLib->hasSufficientPoints(1, 50));
}
/**
* Test getPointsBalance returns correct balance
*/
public function testGetPointsBalanceReturnsCorrectValue(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 250]);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->assertEquals(250, $this->rewardLib->getPointsBalance(1));
}
/**
* Test calculateRewardPaymentAmount with mixed payments
*/
public function testCalculateRewardPaymentAmountWithMixedPayments(): void
{
$payments = [
['payment_type' => 'Cash', 'payment_amount' => 50],
['payment_type' => 'Rewards', 'payment_amount' => 25],
['payment_type' => 'Credit Card', 'payment_amount' => 100],
['payment_type' => 'Rewards', 'payment_amount' => 15],
];
$rewardLabels = ['Rewards', 'Points'];
$total = $this->rewardLib->calculateRewardPaymentAmount($payments, $rewardLabels);
$this->assertEquals(40.0, $total);
}
/**
* Test calculateRewardPaymentAmount with empty payments
*/
public function testCalculateRewardPaymentAmountWithEmptyPayments(): void
{
$total = $this->rewardLib->calculateRewardPaymentAmount([], ['Rewards']);
$this->assertEquals(0.0, $total);
}
/**
* Test calculateRewardPaymentAmount with no matching labels
*/
public function testCalculateRewardPaymentAmountWithNoMatchingLabels(): void
{
$payments = [
['payment_type' => 'Cash', 'payment_amount' => 50],
['payment_type' => 'Credit Card', 'payment_amount' => 100],
];
$total = $this->rewardLib->calculateRewardPaymentAmount($payments, ['Rewards']);
$this->assertEquals(0.0, $total);
}
/**
* Test adjustRewardPoints returns false for null customer
*/
public function testAdjustRewardPointsReturnsFalseForNullCustomer(): void
{
$result = $this->rewardLib->adjustRewardPoints(null, 50, RewardOperation::Deduct);
$this->assertFalse($result);
}
/**
* Test adjustRewardPoints returns false for zero amount
*/
public function testAdjustRewardPointsReturnsFalseForZeroAmount(): void
{
$result = $this->rewardLib->adjustRewardPoints(1, 0, RewardOperation::Deduct);
$this->assertFalse($result);
}
/**
* Test adjustRewardDelta returns false for null customer
*/
public function testAdjustRewardDeltaReturnsFalseForNullCustomer(): void
{
$result = $this->rewardLib->adjustRewardDelta(null, 50);
$this->assertFalse($result);
}
/**
* Test adjustRewardDelta returns false for zero adjustment
*/
public function testAdjustRewardDeltaReturnsFalseForZeroAdjustment(): void
{
$result = $this->rewardLib->adjustRewardDelta(1, 0);
$this->assertFalse($result);
}
/**
* Test handleCustomerChange with same customer returns empty result
*/
public function testHandleCustomerChangeWithSameCustomerReturnsEmpty(): void
{
$result = $this->rewardLib->handleCustomerChange(1, 1, 50.0, 75.0);
$this->assertEquals(['restored' => 0.0, 'charged' => 0.0, 'insufficient' => false], $result);
}
/**
* Test handleCustomerChange with null customers
*/
public function testHandleCustomerChangeWithNullCustomers(): void
{
$result = $this->rewardLib->handleCustomerChange(null, null, 50.0, 75.0);
$this->assertEquals(['restored' => 0.0, 'charged' => 0.0, 'insufficient' => false], $result);
}
/**
* Test handleCustomerChange when customer changes from null to valid customer
*/
public function testHandleCustomerChangeFromNullToValidCustomer(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 100]);
$customerModel->method('update_reward_points_value')
->willReturn(true);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->handleCustomerChange(null, 2, 0, 50.0);
$this->assertEquals(50.0, $result['charged']);
$this->assertEquals(0.0, $result['restored']);
}
/**
* Test update reward points correctly deducts from balance
*/
public function testPointsUpdateDuringSaleCreation(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 200]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(1, 150);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->rewardLib->adjustRewardPoints(1, 50, RewardOperation::Deduct);
}
/**
* Test update reward points correctly restores on sale deletion
*/
public function testPointsRestoreOnSaleDeletion(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 150]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(1, 200);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->rewardLib->adjustRewardPoints(1, 50, RewardOperation::Restore);
}
/**
* Test hasSufficientPoints returns true when points exactly match required
*/
public function testHasSufficientPointsReturnsTrueWhenExactMatch(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 50]);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$this->assertTrue($this->rewardLib->hasSufficientPoints(1, 50));
}
/**
* Test adjustRewardPoints returns false when insufficient points for deduct
*/
public function testAdjustRewardPointsReturnsFalseWhenInsufficientPointsForDeduct(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 30]);
$customerModel->expects($this->never())
->method('update_reward_points_value');
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->adjustRewardPoints(1, 50, RewardOperation::Deduct);
$this->assertFalse($result);
}
/**
* Test adjustRewardDelta returns false when insufficient points for positive adjustment
*/
public function testAdjustRewardDeltaReturnsFalseWhenInsufficientPoints(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 20]);
$customerModel->expects($this->never())
->method('update_reward_points_value');
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->adjustRewardDelta(1, 50);
$this->assertFalse($result);
}
/**
* Test adjustRewardDelta succeeds for negative adjustment (refund)
*/
public function testAdjustRewardDeltaSucceedsForNegativeAdjustment(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 100]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(1, 150);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->adjustRewardDelta(1, -50);
$this->assertTrue($result);
}
/**
* Test handleCustomerChange caps charge at available points when insufficient
*/
public function testHandleCustomerChangeCapsChargeWhenInsufficient(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 30]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(2, 0);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->handleCustomerChange(null, 2, 0, 50.0);
$this->assertEquals(30.0, $result['charged']);
$this->assertTrue($result['insufficient']);
}
/**
* Test handleCustomerChange does not charge when new customer has zero points
*/
public function testHandleCustomerChangeCapsChargeAtZero(): void
{
$customerModel = $this->getMockBuilder(Customer::class)
->onlyMethods(['get_info', 'update_reward_points_value'])
->getMock();
$customerModel->method('get_info')
->willReturn((object)['points' => 0]);
$customerModel->expects($this->once())
->method('update_reward_points_value')
->with(2, 0);
$reflection = new \ReflectionClass($this->rewardLib);
$property = $reflection->getProperty('customer');
$property->setAccessible(true);
$property->setValue($this->rewardLib, $customerModel);
$result = $this->rewardLib->handleCustomerChange(null, 2, 0, 50.0);
$this->assertEquals(0.0, $result['charged']);
$this->assertTrue($result['insufficient']);
}
}

View File

@@ -10,6 +10,15 @@
<testsuite name="Helpers">
<directory>helpers</directory>
</testsuite>
<testsuite name="Libraries">
<directory>Libraries</directory>
</testsuite>
<testsuite name="Models">
<directory>Models</directory>
</testsuite>
<testsuite name="Controllers">
<directory>Controllers</directory>
</testsuite>
</testsuites>
</phpunit>