mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-09-29 22:25:02 -04:00
* fix(security): handle special characters in `.env` key values and improve insertion logic - Escape backslashes and dollar signs in `applyEnvKeyReplacement` to prevent unintended value corruption. - Ensure new keys are inserted after `encryption.key` for better organization and manageability. - Add explicit cast to int to prevent wrong concatenation operator warning. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): handle null return in `applyEnvKeyReplacement` and ensure proper `.env` updates - Update `applyEnvKeyReplacement` to return `null` on failure, improving error handling. - Adjust calls to `atomicWriteFile` with updated content to prevent unintended behavior. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): improve error logging and exception messages in file locking - Add detailed logging for file open and locking errors in `security_helper`. - Remove unused `helper` and `checkThrottleEncryption` calls from `Events` for cleanup. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): improve atomic file write and handle encryption key placement - Throw `RandomException` for better error reporting in `atomicWriteFile`. - Simplify Windows-specific `rename()` fallback logic. - Fix `encryption.key` assignment order to ensure consistency in `.env` updates. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): improve `.env` file handling and add unit tests for helper functions - Suppress warnings in `file_get_contents` to prevent unnecessary error logs. - Update `applyEnvKeyReplacement` to use `preg_replace_callback` for better safety. - Add comprehensive unit tests for `security_helper` functions to ensure `.env` updates and key management work as expected. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): enhance `.env` update logic and add robust exception handling - Add `RandomException` to improve error reporting in encryption key management. - Introduce environment file locking for safer `.env` updates. - Ensure `applyEnvKeyReplacement` properly handles and inserts old key comments. - Replace direct file writes with `atomicWriteFile` for consistency. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): refactor `.env` file initialization and encryption key handling - Introduce `initializeEnvFile` for reusable `.env` setup logic. - Add `backupEnvFile` and `writeNewEncryptionKey` for robust key management with backups. - Simplify and clean up redundant `.env` handling code paths. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): clarify `checkEncryption` docblock return value description Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(security): escape backslashes and dollar signs in `applyEnvKeyReplacement` - Ensure `applyEnvKeyReplacement` properly escapes special characters when inserting or appending `.env` keys. - Add new unit tests to validate correct handling of backslashes and dollar signs. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * fix(i18n): add localized error messages and improve error reporting in `security_helper` - Add missing translations for error messages across multiple language files. - Update `security_helper` to use localized exception messages with placeholders. Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> * Redesign encryption/throttle key provisioning as read-only runtime - checkEncryption()/checkThrottleEncryption() are now read-only guards that throw when no valid key is provisioned, instead of writing .env at request time. - Add rotateEncryptionKey() and provisionThrottleKey() for explicit, idempotent provisioning. - Add php spark env:provision (app/Commands/EnvProvision.php) so Docker can provision keys once at container startup before any request. - Add app/Libraries/CI3SecretConverter.php shared CI3->CI4 secret converter (AES-128-CBC decrypt + CI4 re-encrypt/verify/save) used by both the interactive migration and the docker startup path. - Refactor convertToCI4 migration to use the shared converter. - Persist .env in a named volume and run spark env:provision on boot; stop baking .env into the shipped image. - Add guard/rotation/throttle + converter tests; clean up orphaned msg_pwd_required language keys across all locales. * fix: save CI4 ciphertext in env:provision and bind-mount a .env file Addresses CodeRabbit review on PR #4656: - env:provision CI3 branch was persisting *plaintext* secrets (saveAll($plain)) instead of the CI4 ciphertext, unlike the ConvertToCI4 migration. Now encrypts with encryptAll(), verifies the round trip, and saves the ciphertext. - The ospos_env named volume mounted at /app/.env made .env a directory, so atomicWriteFile's rename() failed and spark env:provision could not start apache. Switch to a bind mount of a host file (./.env) which persists and stays a file. - Add a regression test asserting the command persists ciphertext (not plaintext). * chore: trim redundant docblocks in EnvProvision and provision throttle.key in CI Follow up on @objecttothis review comments: - app/Commands/EnvProvision.php: remove the boilerplate docblocks the property names already convey (group/name/usage/description, run()), the two inline step comments, the anyNonEmpty() param docblock, and the legacySecretsPresent() docblock. Keeps the class-level docblock since it is the only place that states the read-only runtime design + the never-persist-plaintext invariant. - .github/workflows/phpunit.yml: provision a per-run throttle.key the same way the encryption key is already provisioned. The PR makes checkThrottleEncryption() a read-only guard that throws when env('throttle.key') is unset; CI only started exporting ENCRYPTION_KEY, so every test that goes through the Throttle filter (7 ThrottleTest cases + 4 LoginTest cases) failed with "No throttle key is provisioned. Run `php spark env:provision`". Writing `throttle.key=<KEY>` into .env matches what `php spark env:provision` does on a real container start. * fix(ci): write throttle.key into .env instead of exporting an OS env var The previous attempt exported throttle.key via GITHUB_ENV, but CodeIgniter's env() helper resolves in the order $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key), and DotEnv populates $_ENV['throttle.key'] from the .env file first. Because the .env (copied from .env.example) ships with the empty placeholder throttle.key='', that $_ENV entry exists as '' and short-circuits the ?? chain before getenv() is reached — so the OS env var was never consulted and every Throttle/Login test still threw 'No throttle key is provisioned'. Write the per-run key into the .env file itself (sed-replacing the empty placeholder), which is exactly what `php spark env:provision` does in production and is the single source env() actually reads from. Verify the replacement happened (grep -Eq '^throttle\.key=.') so a future change to the placeholder format fails the run loudly instead of silently breaking the 11 throttle-dependent tests. * fix(security): restore CI3->CI4 auto-provisioning gated by .env writability checkEncryption()/checkThrottleEncryption() again provision the keys inline when .env is writable (empty key -> generate; short key -> decrypt, rotate, re-encrypt, verify, persist legacy CI3 secrets). When .env is not writable they assume the key was provisioned externally (e.g. docker env:provision) and throw. Update helper tests to match and correct the EnvProvision docblock that claimed the runtime was strictly read-only. * test(security): make short-key conversion branch injectable and test it checkEncryption() now accepts an optional CI3SecretConverter so the CI3->CI4 conversion branch can be exercised in unit tests without a database. Adds testCheckEncryptionConvertsCi3ShortKeyWhenEnvWritable which seeds CI3-era ciphertexts via a fake Appconfig model and asserts the key is rotated and the payload verifies back to the original plaintext. * fix(security): abort on backup/read/saveAll failure to avoid data loss Three related data-integrity fixes: - backupEnvFile() now returns true/false based on whether the backup actually exists and is readable. rotateEncryptionKey() aborts before destroying the key when the backup could not be written to disk. - rotateEncryptionKey() and provisionThrottleKey() throw RuntimeException(Error.unable_to_read_env_file) when the .env read fails, instead of silently replacing the whole file with an empty string. This prevents a permission error from wiping all keys. - checkEncryption() and EnvProvision::run() now both roll back to the backup with abortEncryptionConversion() when the post-rotation saveAll() throws, matching the migration path (which already did this). A failing fake Appconfig is used to exercise this in the new testCheckEncryptionRollsBackWhenSaveAllFails test. * fix(ci): skip comment job in deploy-pr.yml when prepare was not run The comment job had if: always(), so it ran even when the prepare job was skipped (e.g. review was not approved). With PR_NUMBER empty the gh api call posted to issues//comments, received a 404, and the entire run showed up as failure. Guard the job with needs.prepare.result == 'success' so it only runs when PR_NUMBER is valid. * address coderabbit open items: placeholder guards, message neutrality, ar-EG alignment - backupEnvFile(): fail when mkdir() or either chmod() fails, so the pre-rotation backup is actually persisted before the key is replaced - email/message config views: only show the 'already set' placeholder when the secret is actually present (prevented false positives on fresh installs) - Error.unable_to_create_env_file / .unable_to_read_env_file (en + en-GB): use key-neutral wording since both keys are provisioned with the same keys - ar-EG/Error.php: align all => arrows on the longest key Item 7 (filesystem test isolation) is a larger refactor — the tests are serial on CI and tearDown() restores state per test. Left for follow-up. * test(security): isolate helper FS tests via Config\SecurityEnv Introduce Config\SecurityEnv holding envPath/backupPath/lockPath so the security helper reads its target paths from shared configuration instead of hardcoded ROOTPATH/WRITEPATH literals. security_helperTest.php now redirects all three to a unique per-run sandbox under sys_get_temp_dir() and tears it down in tearDown(), so the suite no longer reads/writes the repository's real .env and is safe to run in parallel. No helper signature changes; production callers unaffected. Addresses CodeRabbit item 7 (issue #4700). Co-Authored-By: opencode <bot@opencode.ai> * fix(security): run key-conversion as one locked transaction Address CodeRabbit Major findings from the 4th re-review of the env helper and its callers: 1. Hold .env.lock for the entire CI3 -> CI4 conversion transaction (backup -> rotate -> re-encrypt -> verify -> persist -> cleanup) so a concurrent worker cannot interleave a key write between the rotation and the ciphertext save. Split rotateEncryptionKey into a lock-free core (rotateEncryptionKeyUnlock) plus the existing lock wrapper and a new rotateEncryptionKeyTransaction that owns the lock across the full unit and performs both the in-lock rollback (abortEncryptionConversion) and the in-lock backup removal on success. 2. Treat the legacy value '0' as non-empty data so key rotation still persists the re-encrypted ciphertext when '0' is the only stored secret (array_filter would have dropped it and skipped saveAll). 3. Wrap the post-rotation re-encrypt/verify/saveAll sequence in a catch (Throwable) across all three call-sites so CI4 EncryptionException, ReflectionException from batch_save, a failed round-trip verify, and any other failure all roll the .env key back to the pre-rotation state. 4. In Docker Compose, use long-syntax bind with create_host_path: false and document in INSTALL.md that the host .env must be a regular file (a missing one is no longer auto-created as a directory, and the mount now rejects a missing source on Compose implementations that support the flag). Files touched: app/Helpers/security_helper.php, app/Commands/EnvProvision.php, app/Database/Migrations/20220127000000_convertToCI4.php, docker-compose.yml, INSTALL.md. All 4 existing helper tests still pass via CI. * fix(security): make abortEncryptionConversion fail loudly on restore failure The rollback path restored the .env backup with a suppressed file_put_contents() and an unchecked file_get_contents(). If the restore failed after the key had already been rotated, .env was left holding the new CI4 key while the DB still held CI3-era ciphertext, so the data became undecryptable after the next restart. Now the backup read is checked for false and the restore goes through the existing atomicWriteFile() helper; either failure throws so the error is surfaced instead of silently corrupting the config. Adds a regression test that forces an unreadable backup and asserts the throw plus that .env is left untouched. * fix(security): guard abortEncryptionConversion backup read before touching it Validate the backup is a regular readable file (is_file/is_readable) before reading it, so a missing/malformed backup fails loudly instead of emitting a file_get_contents() warning. The unreadable-backup regression test now exercises this guard rather than relying on a promoted warning. --------- Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com> Co-authored-by: jekkos <jeroen.peelaerts@gmail.com> Co-authored-by: jekkos <jekkos@users.noreply.github.com> Co-authored-by: opencode <bot@opencode.ai>
1120 lines
48 KiB
PHP
1120 lines
48 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Libraries\Barcode_lib;
|
|
use App\Libraries\Mailchimp_lib;
|
|
use App\Libraries\Receiving_lib;
|
|
use App\Libraries\Sale_lib;
|
|
use App\Libraries\Tax_lib;
|
|
use App\Models\Appconfig;
|
|
use App\Models\Attribute;
|
|
use App\Models\Customer_rewards;
|
|
use App\Models\Dinner_table;
|
|
use App\Models\Item;
|
|
use App\Models\Module;
|
|
use App\Models\Enums\Rounding_mode;
|
|
use App\Models\Stock_location;
|
|
use App\Models\Tax;
|
|
use CodeIgniter\Database\BaseConnection;
|
|
use CodeIgniter\Encryption\EncrypterInterface;
|
|
use CodeIgniter\HTTP\ResponseInterface;
|
|
use Config\Database;
|
|
use Config\OSPOS;
|
|
use Config\Services;
|
|
use DirectoryIterator;
|
|
use NumberFormatter;
|
|
use ReflectionException;
|
|
|
|
class Config extends Secure_Controller
|
|
{
|
|
protected $helpers = ['security'];
|
|
private BaseConnection $db;
|
|
private EncrypterInterface $encrypter;
|
|
private Barcode_lib $barcode_lib;
|
|
private Sale_lib $sale_lib;
|
|
private Receiving_lib $receiving_lib;
|
|
private Tax_lib $tax_lib;
|
|
private Appconfig $appconfig;
|
|
private Attribute $attribute;
|
|
private Customer_rewards $customer_rewards;
|
|
private Dinner_table $dinner_table;
|
|
protected Module $module;
|
|
private Stock_location $stock_location;
|
|
private Tax $tax;
|
|
private array $config;
|
|
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct('config');
|
|
|
|
$this->barcode_lib = new Barcode_lib();
|
|
$this->sale_lib = new Sale_lib();
|
|
$this->receiving_lib = new receiving_lib();
|
|
$this->tax_lib = new Tax_lib();
|
|
$this->appconfig = model(Appconfig::class);
|
|
$this->attribute = model(Attribute::class);
|
|
$this->customer_rewards = model(Customer_rewards::class);
|
|
$this->dinner_table = model(Dinner_table::class);
|
|
$this->module = model(Module::class);
|
|
$this->stock_location = model(Stock_location::class);
|
|
$this->tax = model(Tax::class);
|
|
$this->config = config(OSPOS::class)->settings;
|
|
$this->db = Database::connect();
|
|
|
|
helper('security');
|
|
if (checkEncryption()) {
|
|
$this->encrypter = Services::encrypter();
|
|
} else {
|
|
log_message('alert', 'Error preparing encryption key');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This function loads all the licenses starting with the first one being OSPOS one
|
|
*/
|
|
private function _licenses(): array // TODO: remove hungarian notation. Super long function. Perhaps we need to refactor out functions?
|
|
{
|
|
$i = 0;
|
|
$composer = false;
|
|
$npmProd = false;
|
|
$npmDev = false;
|
|
$license = [];
|
|
|
|
$license[$i]['title'] = 'Open Source Point of Sale ' . config('App')->application_version;
|
|
|
|
if (file_exists('license/LICENSE')) {
|
|
$license[$i]['text'] = file_get_contents('license/LICENSE', false, null, 0, 3000);
|
|
} else {
|
|
$license[$i]['text'] = 'LICENSE file must be in OSPOS license directory. You are not allowed to use OSPOS application until the distribution copy of LICENSE file is present.';
|
|
}
|
|
|
|
$dir = new DirectoryIterator('license'); // Read all the files in the dir license
|
|
|
|
foreach ($dir as $fileinfo) { // TODO: $fileinfo doesn't match our variable naming convention
|
|
// License files must be in couples: .version (name & version) & .license (license text)
|
|
if ($fileinfo->isFile()) {
|
|
if ($fileinfo->getExtension() == 'version') {
|
|
++$i;
|
|
|
|
$basename = 'license/' . $fileinfo->getBasename('.version');
|
|
|
|
$license[$i]['title'] = file_get_contents($basename . '.version', false, null, 0, 100);
|
|
|
|
$license_text_file = $basename . '.license';
|
|
|
|
if (file_exists($license_text_file)) {
|
|
$license[$i]['text'] = file_get_contents($license_text_file, false, null, 0, 2000);
|
|
} else {
|
|
$license[$i]['text'] = $license_text_file . ' file is missing';
|
|
}
|
|
} elseif ($fileinfo->getBasename() == 'composer.LICENSES') {
|
|
// Set a flag to indicate that the composer.LICENSES file is available and needs to be attached at the end
|
|
$composer = true;
|
|
} elseif ($fileinfo->getBasename() == 'npm-prod.LICENSES') {
|
|
// Set a flag to indicate that the npm-prod.LICENSES file is available and needs to be attached at the end
|
|
$npmProd = true;
|
|
} elseif ($fileinfo->getBasename() == 'npm-dev.LICENSES') {
|
|
// Set a flag to indicate that the npm-dev.LICENSES file is available and needs to be attached at the end
|
|
$npmDev = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Attach the licenses from the LICENSES file generated by Composer
|
|
if ($composer) {
|
|
++$i;
|
|
$license[$i]['title'] = 'Composer Libraries';
|
|
$license[$i]['text'] = '';
|
|
|
|
$file = file_get_contents('license/composer.LICENSES');
|
|
$array = json_decode($file, true);
|
|
|
|
if (isset($array['dependencies'])) {
|
|
foreach ($array['dependencies'] as $dependency => $details) {
|
|
$license[$i]['text'] .= "library: $dependency\n";
|
|
|
|
foreach ($details as $key => $value) {
|
|
if (is_array($value)) {
|
|
$license[$i]['text'] .= "$key: " . implode(' ', $value) . "\n";
|
|
} else {
|
|
$license[$i]['text'] .= "$key: $value\n";
|
|
}
|
|
}
|
|
|
|
$license[$i]['text'] .= "\n";
|
|
}
|
|
$license[$i]['text'] = rtrim($license[$i]['text'], "\n");
|
|
}
|
|
}
|
|
|
|
// Attach the licenses from the LICENSES file generated by license-report
|
|
if ($npmProd) {
|
|
++$i;
|
|
$license[$i]['title'] = 'NPM Production Libraries';
|
|
$license[$i]['text'] = '';
|
|
|
|
$file = file_get_contents('license/npm-prod.LICENSES');
|
|
$array = json_decode($file, true);
|
|
|
|
if (is_array($array)) {
|
|
foreach ($array as $dependency) {
|
|
if (!is_array($dependency) || count(array_intersect(['name', 'author', 'homepage', 'installedVersion', 'licenseType'], array_keys($dependency))) !== 5) {
|
|
continue;
|
|
}
|
|
|
|
$license[$i]['text'] .= "library: {$dependency['name']}\n";
|
|
$license[$i]['text'] .= "authors: {$dependency['author']}\n";
|
|
$license[$i]['text'] .= "website: {$dependency['homepage']}\n";
|
|
$license[$i]['text'] .= "version: {$dependency['installedVersion']}\n";
|
|
$license[$i]['text'] .= "license: {$dependency['licenseType']}\n";
|
|
|
|
$license[$i]['text'] .= "\n";
|
|
}
|
|
}
|
|
$license[$i]['text'] = rtrim($license[$i]['text'], "\n");
|
|
}
|
|
|
|
if ($npmDev) {
|
|
++$i;
|
|
$license[$i]['title'] = 'NPM Development Libraries';
|
|
$license[$i]['text'] = '';
|
|
|
|
$file = file_get_contents('license/npm-dev.LICENSES');
|
|
$array = json_decode($file, true);
|
|
|
|
if (is_array($array)) {
|
|
foreach ($array as $dependency) {
|
|
if (!is_array($dependency) || count(array_intersect(['name', 'author', 'homepage', 'installedVersion', 'licenseType'], array_keys($dependency))) !== 5) {
|
|
continue;
|
|
}
|
|
|
|
$license[$i]['text'] .= "library: {$dependency['name']}\n";
|
|
$license[$i]['text'] .= "authors: {$dependency['author']}\n";
|
|
$license[$i]['text'] .= "website: {$dependency['homepage']}\n";
|
|
$license[$i]['text'] .= "version: {$dependency['installedVersion']}\n";
|
|
$license[$i]['text'] .= "license: {$dependency['licenseType']}\n";
|
|
|
|
$license[$i]['text'] .= "\n";
|
|
}
|
|
}
|
|
$license[$i]['text'] = rtrim($license[$i]['text'], "\n");
|
|
}
|
|
|
|
return $license;
|
|
}
|
|
|
|
/**
|
|
* This function loads all the available themes in the dist/bootswatch directory
|
|
* @return array
|
|
*/
|
|
private function _themes(): array // TODO: Hungarian notation
|
|
{
|
|
$themes = [];
|
|
|
|
// Read all themes in the dist folder
|
|
$dir = new DirectoryIterator('resources/bootswatch');
|
|
|
|
foreach ($dir as $dirinfo) { // TODO: $dirinfo doesn't follow naming convention
|
|
if ($dirinfo->isDir() && !$dirinfo->isDot() && $dirinfo->getFileName() != 'fonts') {
|
|
$file = $dirinfo->getFileName();
|
|
$themes[$file] = ucfirst($file);
|
|
}
|
|
}
|
|
|
|
asort($themes);
|
|
|
|
return $themes;
|
|
}
|
|
|
|
/**
|
|
* @return string
|
|
*/
|
|
public function getIndex(): string
|
|
{
|
|
$data['config'] = $this->config;
|
|
$data['stock_locations'] = $this->stock_location->get_all()->getResultArray();
|
|
$data['dinner_tables'] = $this->dinner_table->get_all()->getResultArray();
|
|
$data['customer_rewards'] = $this->customer_rewards->get_all()->getResultArray();
|
|
$data['support_barcode'] = $this->barcode_lib->get_list_barcodes();
|
|
$data['barcode_fonts'] = $this->barcode_lib->listfonts('fonts');
|
|
$data['logo_exists'] = $this->config['company_logo'] != '';
|
|
$data['logo_src'] = !empty($this->config['company_logo']) ? base_url('uploads/' . $this->config['company_logo']) : '';
|
|
$data['line_sequence_options'] = $this->sale_lib->get_line_sequence_options();
|
|
$data['register_mode_options'] = $this->sale_lib->get_register_mode_options();
|
|
$data['invoice_type_options'] = $this->sale_lib->get_invoice_type_options();
|
|
$data['keyboardShortcutOptions'] = $this->sale_lib->getKeyShortcutsOptions();
|
|
$data['keyboardShortcuts'] = $this->sale_lib->getKeyShortcuts();
|
|
$data['rounding_options'] = rounding_mode::get_rounding_options();
|
|
$data['tax_code_options'] = $this->tax_lib->get_tax_code_options();
|
|
$data['tax_category_options'] = $this->tax_lib->get_tax_category_options();
|
|
$data['tax_jurisdiction_options'] = $this->tax_lib->get_tax_jurisdiction_options();
|
|
$data['show_office_group'] = $this->module->get_show_office_group();
|
|
$data['currency_code'] = $this->config['currency_code'] ?? '';
|
|
$data['dbVersion'] = mysqli_get_server_info($this->db->getConnection());
|
|
|
|
// Load all the license statements, they are already XSS cleaned in the private function
|
|
$data['licenses'] = $this->_licenses();
|
|
|
|
// Load all the themes, already XSS cleaned in the private function
|
|
$data['themes'] = $this->_themes();
|
|
|
|
// General related fields
|
|
$image_allowed_types = ['jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'png', 'tif', 'tiff'];
|
|
$data['image_allowed_types'] = array_combine($image_allowed_types, $image_allowed_types);
|
|
$data['selected_image_allowed_types'] = explode(',', $this->config['image_allowed_types']);
|
|
|
|
// Integrations Related fields
|
|
$data['mailchimp'] = [];
|
|
$data['smtp_pass_set'] = !empty($this->config['smtp_pass']);
|
|
$data['msg_pwd_set'] = !empty($this->config['msg_pwd']);
|
|
|
|
if (checkEncryption()) { // TODO: Hungarian notation
|
|
if (!isset($this->encrypter)) {
|
|
helper('security');
|
|
$this->encrypter = Services::encrypter();
|
|
}
|
|
|
|
$data['mailchimp']['api_key'] = '';
|
|
$data['mailchimp']['api_key_set'] = !empty($this->config['mailchimp_api_key']);
|
|
|
|
$data['mailchimp']['list_id'] = (isset($this->config['mailchimp_list_id']) && !empty($this->config['mailchimp_list_id']))
|
|
? $this->encrypter->decrypt($this->config['mailchimp_list_id'])
|
|
: '';
|
|
|
|
// Remove any backup of .env created by check_encryption()
|
|
removeBackup();
|
|
} else {
|
|
$data['mailchimp']['api_key'] = '';
|
|
$data['mailchimp']['api_key_set'] = false;
|
|
$data['mailchimp']['list_id'] = '';
|
|
}
|
|
|
|
$data['mailchimp']['lists'] = $this->_mailchimp();
|
|
|
|
return view('configs/manage', $data);
|
|
}
|
|
|
|
/**
|
|
* Saves company information. Used in app/Views/configs/info_config.php
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveInfo(): ResponseInterface
|
|
{
|
|
$upload_data = $this->upload_logo();
|
|
$upload_success = empty($upload_data['error']);
|
|
|
|
$batch_save_data = [
|
|
'company' => $this->request->getPost('company'),
|
|
'address' => $this->request->getPost('address'),
|
|
'phone' => $this->request->getPost('phone'),
|
|
'email' => strtolower($this->request->getPost('email', FILTER_SANITIZE_EMAIL)),
|
|
'fax' => $this->request->getPost('fax'),
|
|
'website' => $this->request->getPost('website', FILTER_SANITIZE_URL),
|
|
'return_policy' => $this->request->getPost('return_policy')
|
|
];
|
|
|
|
if (!empty($upload_data['orig_name']) && $upload_data['raw_name']) {
|
|
$batch_save_data['company_logo'] = $upload_data['raw_name'] . '.' . $upload_data['file_ext'];
|
|
}
|
|
|
|
$result = $this->appconfig->batch_save($batch_save_data);
|
|
$success = $upload_success && $result;
|
|
$message = lang('Config.saved_' . ($success ? '' : 'un') . 'successfully');
|
|
$message = $upload_success ? $message : strip_tags($upload_data['error']);
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => $message]);
|
|
}
|
|
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
private function upload_logo(): array
|
|
{
|
|
$file = $this->request->getFile('company_logo');
|
|
if (!$file) {
|
|
return [];
|
|
}
|
|
|
|
helper(['form']);
|
|
$validation_rule = [
|
|
'company_logo' => [
|
|
'label' => 'Company logo',
|
|
'rules' => [
|
|
'uploaded[company_logo]',
|
|
'is_image[company_logo]',
|
|
'max_size[company_logo,1024]',
|
|
'mime_in[company_logo,image/png,image/jpg,image/jpeg,image/gif]',
|
|
'ext_in[company_logo,png,jpg,gif]',
|
|
'max_dims[company_logo,800,680]',
|
|
]
|
|
]
|
|
];
|
|
|
|
if (!$this->validate($validation_rule)) {
|
|
return (['error' => $this->validator->getError('company_logo')]);
|
|
}
|
|
|
|
|
|
$filename = $file->getClientName();
|
|
$info = pathinfo($filename);
|
|
helper('security');
|
|
|
|
$file_info = [
|
|
'orig_name' => $filename,
|
|
'raw_name' => sanitize_filename($info['filename']),
|
|
'file_ext' => $file->guessExtension()
|
|
];
|
|
|
|
$file->move(FCPATH . 'uploads/', $file_info['raw_name'] . '.' . $file_info['file_ext'], true);
|
|
|
|
return ($file_info);
|
|
}
|
|
|
|
/**
|
|
* Saves general configuration. Used in app/Views/configs/general_config.php
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveGeneral(): ResponseInterface
|
|
{
|
|
$rules = [
|
|
'theme' => 'permit_empty|themeExists',
|
|
];
|
|
if (!$this->validate($rules)) {
|
|
$errors = $this->validator->getErrors();
|
|
return $this->response->setJSON(['success' => false, 'message' => reset($errors)]);
|
|
}
|
|
|
|
$batchSaveData = [
|
|
'theme' => $this->request->getPost('theme'),
|
|
'login_form' => $this->request->getPost('login_form'),
|
|
'default_sales_discount_type' => $this->request->getPost('default_sales_discount_type') != null,
|
|
'default_sales_discount' => parse_decimals($this->request->getPost('default_sales_discount')),
|
|
'default_receivings_discount_type' => $this->request->getPost('default_receivings_discount_type') != null,
|
|
'default_receivings_discount' => parse_decimals($this->request->getPost('default_receivings_discount')),
|
|
'enforce_privacy' => $this->request->getPost('enforce_privacy') != null,
|
|
'receiving_calculate_average_price' => $this->request->getPost('receiving_calculate_average_price') != null,
|
|
'lines_per_page' => $this->request->getPost('lines_per_page', FILTER_SANITIZE_NUMBER_INT),
|
|
'notify_horizontal_position' => $this->request->getPost('notify_horizontal_position'),
|
|
'notify_vertical_position' => $this->request->getPost('notify_vertical_position'),
|
|
'image_max_width' => $this->request->getPost('image_max_width', FILTER_SANITIZE_NUMBER_INT),
|
|
'image_max_height' => $this->request->getPost('image_max_height', FILTER_SANITIZE_NUMBER_INT),
|
|
'image_max_size' => $this->request->getPost('image_max_size', FILTER_SANITIZE_NUMBER_INT),
|
|
'image_allowed_types' => implode(',', $this->request->getPost('image_allowed_types')),
|
|
'gcaptcha_enable' => $this->request->getPost('gcaptcha_enable') != null,
|
|
'gcaptcha_secret_key' => $this->request->getPost('gcaptcha_secret_key'),
|
|
'gcaptcha_site_key' => $this->request->getPost('gcaptcha_site_key'),
|
|
'suggestions_first_column' => $this->validateSuggestionsColumn($this->request->getPost('suggestions_first_column'), 'first'),
|
|
'suggestions_second_column' => $this->validateSuggestionsColumn($this->request->getPost('suggestions_second_column'), 'other'),
|
|
'suggestions_third_column' => $this->validateSuggestionsColumn($this->request->getPost('suggestions_third_column'), 'other'),
|
|
'giftcard_number' => $this->request->getPost('giftcard_number'),
|
|
'derive_sale_quantity' => $this->request->getPost('derive_sale_quantity') != null,
|
|
'multi_pack_enabled' => $this->request->getPost('multi_pack_enabled') != null,
|
|
'include_hsn' => $this->request->getPost('include_hsn') != null,
|
|
'category_dropdown' => $this->request->getPost('category_dropdown') != null
|
|
];
|
|
|
|
$this->module->set_show_office_group($this->request->getPost('show_office_group') != null);
|
|
|
|
$this->db->transStart();
|
|
|
|
$attributeSuccess = true;
|
|
if ($batchSaveData['category_dropdown']) {
|
|
$definitionData['definition_name'] = 'ospos_category';
|
|
$definitionData['definition_flags'] = 0;
|
|
$definitionData['definition_type'] = 'DROPDOWN';
|
|
$definitionData['definition_id'] = CATEGORY_DEFINITION_ID;
|
|
$definitionData['deleted'] = 0;
|
|
|
|
$attributeSuccess = $this->attribute->saveDefinition($definitionData, CATEGORY_DEFINITION_ID);
|
|
} elseif ($batchSaveData['category_dropdown'] == NO_DEFINITION_ID) {
|
|
$attributeSuccess = $this->attribute->deleteDefinition(CATEGORY_DEFINITION_ID);
|
|
}
|
|
|
|
$success = $attributeSuccess && $this->appconfig->batch_save($batchSaveData);
|
|
|
|
$this->db->transComplete();
|
|
|
|
$success = $success && $this->db->transStatus();
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Checks a number against the currently selected locale. Used in app/Views/configs/locale_config.php
|
|
*
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postCheckNumberLocale(): ResponseInterface
|
|
{
|
|
$numberLocale = $this->request->getPost('number_locale');
|
|
$saveNumberLocale = $this->request->getPost('save_number_locale');
|
|
$postedCurrencySymbol = $this->request->getPost('currency_symbol');
|
|
$postedCurrencyCode = $this->request->getPost('currency_code');
|
|
|
|
$fmt = new NumberFormatter($numberLocale, NumberFormatter::CURRENCY);
|
|
|
|
// Use posted values if provided, otherwise fall back to locale defaults
|
|
$currencySymbol = $postedCurrencySymbol !== '' ? $postedCurrencySymbol : $fmt->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
|
|
$currencyCode = $postedCurrencyCode !== '' ? $postedCurrencyCode : $fmt->getTextAttribute(NumberFormatter::CURRENCY_CODE);
|
|
|
|
// Update saved locale if it changed
|
|
if ($numberLocale !== $saveNumberLocale) {
|
|
$saveNumberLocale = $numberLocale;
|
|
}
|
|
|
|
if ($this->request->getPost('thousands_separator') == 'false') {
|
|
$fmt->setTextAttribute(NumberFormatter::GROUPING_SEPARATOR_SYMBOL, '');
|
|
}
|
|
|
|
$fmt->setSymbol(NumberFormatter::CURRENCY_SYMBOL, $currencySymbol);
|
|
$numberLocaleExample = $fmt->format(1234567890.12300);
|
|
|
|
return $this->response->setJSON([
|
|
'success' => $numberLocaleExample != false,
|
|
'save_number_locale' => $saveNumberLocale,
|
|
'number_locale_example' => $numberLocaleExample,
|
|
'currency_symbol' => $currencySymbol,
|
|
'currency_code' => $currencyCode,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Saves locale configuration. Used in app/Views/configs/locale_config.php
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveLocale(): ResponseInterface
|
|
{
|
|
$rules = [
|
|
'payment_reference_code_min' => 'required|integer|greater_than[0]',
|
|
'payment_reference_code_max' => 'required|integer|gte_field[payment_reference_code_min]',
|
|
];
|
|
if (!$this->validate($rules)) {
|
|
$errors = $this->validator->getErrors();
|
|
return $this->response->setJSON(['success' => false, 'message' => reset($errors)]);
|
|
}
|
|
|
|
$exploded = explode(":", $this->request->getPost('language'));
|
|
$currency_symbol = $this->request->getPost('currency_symbol');
|
|
$batch_save_data = [
|
|
'currency_symbol' => htmlspecialchars($currency_symbol ?? ''),
|
|
'currency_code' => $this->request->getPost('currency_code'),
|
|
'language_code' => $exploded[0],
|
|
'language' => $exploded[1],
|
|
'timezone' => $this->request->getPost('timezone'),
|
|
'dateformat' => $this->request->getPost('dateformat'),
|
|
'timeformat' => $this->request->getPost('timeformat'),
|
|
'thousands_separator' => $this->request->getPost('thousands_separator') != null,
|
|
'number_locale' => $this->request->getPost('number_locale'),
|
|
'currency_decimals' => $this->request->getPost('currency_decimals', FILTER_SANITIZE_NUMBER_INT),
|
|
'tax_decimals' => $this->request->getPost('tax_decimals', FILTER_SANITIZE_NUMBER_INT),
|
|
'quantity_decimals' => $this->request->getPost('quantity_decimals', FILTER_SANITIZE_NUMBER_INT),
|
|
'country_codes' => htmlspecialchars($this->request->getPost('country_codes')),
|
|
'payment_options_order' => $this->request->getPost('payment_options_order'),
|
|
'payment_reference_code_min' => $this->request->getPost('payment_reference_code_min', FILTER_SANITIZE_NUMBER_INT),
|
|
'payment_reference_code_max' => $this->request->getPost('payment_reference_code_max', FILTER_SANITIZE_NUMBER_INT),
|
|
'date_or_time_format' => $this->request->getPost('date_or_time_format') != null,
|
|
'cash_decimals' => $this->request->getPost('cash_decimals', FILTER_SANITIZE_NUMBER_INT),
|
|
'cash_rounding_code' => $this->request->getPost('cash_rounding_code'),
|
|
'financial_year' => $this->request->getPost('financial_year', FILTER_SANITIZE_NUMBER_INT)
|
|
];
|
|
|
|
$success = $this->appconfig->batch_save($batch_save_data);
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Saves email configuration. Used in app/Views/configs/email_config.php
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveEmail(): ResponseInterface
|
|
{
|
|
$postedPass = (string) $this->request->getPost('smtp_pass');
|
|
|
|
if (checkEncryption() && $postedPass !== '') {
|
|
$password = $this->encrypter->encrypt($postedPass);
|
|
} else {
|
|
$password = (string) ($this->config['smtp_pass'] ?? '');
|
|
}
|
|
|
|
$protocol = $this->request->getPost('protocol');
|
|
$mailpath = $this->request->getPost('mailpath');
|
|
|
|
$rules = [
|
|
'mailpath' => [
|
|
'label' => lang('Config.email_mailpath'),
|
|
'rules' => ($protocol === 'sendmail' ? 'required' : 'permit_empty') . '|valid_path_strict'
|
|
]
|
|
];
|
|
$messages = [
|
|
'mailpath' => [
|
|
'required' => lang('Config.mailpath_invalid'),
|
|
'valid_path_strict' => lang('Config.mailpath_invalid')
|
|
]
|
|
];
|
|
|
|
if ($error = $this->validateFields($rules, $messages)) {
|
|
return $error;
|
|
}
|
|
|
|
$batch_save_data = [
|
|
'protocol' => $protocol,
|
|
'mailpath' => $mailpath,
|
|
'smtp_host' => $this->request->getPost('smtp_host'),
|
|
'smtp_user' => $this->request->getPost('smtp_user'),
|
|
'smtp_pass' => $password,
|
|
'smtp_port' => $this->request->getPost('smtp_port', FILTER_SANITIZE_NUMBER_INT),
|
|
'smtp_timeout' => $this->request->getPost('smtp_timeout', FILTER_SANITIZE_NUMBER_INT),
|
|
'smtp_crypto' => $this->request->getPost('smtp_crypto')
|
|
];
|
|
|
|
$success = $this->appconfig->batch_save($batch_save_data);
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Saves SMS message configuration. Used in app/Views/configs/message_config.php.
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveMessage(): ResponseInterface
|
|
{
|
|
$postedPwd = (string) $this->request->getPost('msg_pwd');
|
|
|
|
if (checkEncryption() && $postedPwd !== '') {
|
|
$password = $this->encrypter->encrypt($postedPwd);
|
|
} else {
|
|
$password = (string) ($this->config['msg_pwd'] ?? '');
|
|
}
|
|
|
|
$batch_save_data = [
|
|
'msg_msg' => $this->request->getPost('msg_msg'),
|
|
'msg_uid' => $this->request->getPost('msg_uid'),
|
|
'msg_pwd' => $password,
|
|
'msg_src' => $this->request->getPost('msg_src')
|
|
];
|
|
|
|
$success = $this->appconfig->batch_save($batch_save_data);
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* This function fetches all the available lists from Mailchimp for the given API key
|
|
*/
|
|
private function _mailchimp(string $api_key = ''): array // TODO: Hungarian notation
|
|
{
|
|
$mailchimp_lib = new Mailchimp_lib(['api_key' => $api_key]);
|
|
|
|
$result = [];
|
|
|
|
$lists = $mailchimp_lib->getLists();
|
|
if ($lists !== false) {
|
|
if (is_array($lists) && !empty($lists['lists']) && is_array($lists['lists'])) {
|
|
foreach ($lists['lists'] as $list) {
|
|
$result[$list['id']] = $list['name'] . ' [' . $list['stats']['member_count'] . ']';
|
|
}
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Gets Mailchimp lists when a valid API key is inserted. Used in app/Views/configs/integrations_config.php
|
|
*
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postCheckMailchimpApiKey(): ResponseInterface
|
|
{
|
|
$lists = $this->_mailchimp($this->request->getPost('mailchimp_api_key'));
|
|
$success = count($lists) > 0;
|
|
|
|
return $this->response->setJSON([
|
|
'success' => $success,
|
|
'message' => lang('Config.mailchimp_key_' . ($success ? '' : 'un') . 'successfully'),
|
|
'mailchimp_lists' => $lists
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Saves Mailchimp configuration. Used in app/Views/configs/integrations_config.php
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveMailchimp(): ResponseInterface
|
|
{
|
|
$postedKey = (string) $this->request->getPost('mailchimp_api_key');
|
|
$postedList = (string) $this->request->getPost('mailchimp_list_id');
|
|
|
|
if (checkEncryption()) {
|
|
$api_key = $postedKey !== ''
|
|
? $this->encrypter->encrypt($postedKey)
|
|
: (string) ($this->config['mailchimp_api_key'] ?? '');
|
|
|
|
$list_id = $postedList !== ''
|
|
? $this->encrypter->encrypt($postedList)
|
|
: (string) ($this->config['mailchimp_list_id'] ?? '');
|
|
} else {
|
|
$api_key = $postedKey !== ''
|
|
? (string) $postedKey
|
|
: (string) ($this->config['mailchimp_api_key'] ?? '');
|
|
|
|
$list_id = $postedList !== ''
|
|
? (string) $postedList
|
|
: (string) ($this->config['mailchimp_list_id'] ?? '');
|
|
}
|
|
|
|
$batch_save_data = ['mailchimp_api_key' => $api_key, 'mailchimp_list_id' => $list_id];
|
|
|
|
$success = $this->appconfig->batch_save($batch_save_data);
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Gets all stock locations. Used in app/Views/configs/stock_config.php
|
|
*
|
|
* @return string
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function getStockLocations(): string
|
|
{
|
|
$stock_locations = $this->stock_location->get_all()->getResultArray();
|
|
|
|
return view('partial/stock_locations', ['stock_locations' => $stock_locations]);
|
|
}
|
|
|
|
/**
|
|
* @return string
|
|
*/
|
|
public function getDinnerTables(): string
|
|
{
|
|
$dinner_tables = $this->dinner_table->get_all()->getResultArray();
|
|
|
|
return view('partial/dinner_tables', ['dinner_tables' => $dinner_tables]);
|
|
}
|
|
|
|
|
|
/**
|
|
* Gets all tax categories.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function ajax_tax_categories(): string // TODO: Is this function called anywhere in the code?
|
|
{
|
|
$tax_categories = $this->tax->get_all_tax_categories()->getResultArray();
|
|
|
|
return view('partial/tax_categories', ['tax_categories' => $tax_categories]);
|
|
}
|
|
|
|
/**
|
|
* Gets all customer rewards. Used in app/Views/configs/reward_config.php
|
|
*
|
|
* @return string
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function getCustomerRewards(): string
|
|
{
|
|
$customer_rewards = $this->customer_rewards->get_all()->getResultArray();
|
|
|
|
return view('partial/customer_rewards', ['customer_rewards' => $customer_rewards]);
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
*/
|
|
private function _clear_session_state(): void // TODO: Hungarian notation
|
|
{
|
|
$this->sale_lib->clear_sale_location();
|
|
$this->sale_lib->clear_table();
|
|
$this->sale_lib->clear_all();
|
|
$this->receiving_lib = new Receiving_lib();
|
|
$this->receiving_lib->clear_stock_source();
|
|
$this->receiving_lib->clear_stock_destination();
|
|
$this->receiving_lib->clear_all();
|
|
}
|
|
|
|
/**
|
|
* Saves stock locations. Used in app/Views/configs/stock_config.php
|
|
*
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveLocations(): ResponseInterface
|
|
{
|
|
$this->db->transStart();
|
|
|
|
$not_to_delete = [];
|
|
foreach ($this->request->getPost() as $key => $value) {
|
|
if (str_contains($key, 'stock_location')) {
|
|
// Save or update
|
|
foreach ($value as $location_id => $location_name) {
|
|
$location_data = ['location_name' => $location_name];
|
|
if ($this->stock_location->save_value($location_data, $location_id)) {
|
|
$location_id = $this->stock_location->get_location_id($location_name);
|
|
$not_to_delete[] = $location_id;
|
|
$this->_clear_session_state();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// All locations not available in post will be deleted now
|
|
$deleted_locations = $this->stock_location->get_all()->getResultArray();
|
|
|
|
foreach ($deleted_locations as $location => $location_data) {
|
|
if (!in_array($location_data['location_id'], $not_to_delete)) {
|
|
$this->stock_location->delete($location_data['location_id']);
|
|
}
|
|
}
|
|
|
|
$this->db->transComplete();
|
|
|
|
$success = $this->db->transStatus();
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Saves all dinner tables. Used in app/Views/configs/table_config.php
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveTables(): ResponseInterface
|
|
{
|
|
$this->db->transStart();
|
|
|
|
$dinner_table_enable = $this->request->getPost('dinner_table_enable') != null;
|
|
|
|
$this->appconfig->save(['dinner_table_enable' => $dinner_table_enable]);
|
|
|
|
if ($dinner_table_enable) {
|
|
$not_to_delete = [];
|
|
foreach ($this->request->getPost() as $key => $value) { // TODO: Not sure if this is the best way to filter the array
|
|
if (strstr($key, 'dinner_table') && $key != 'dinner_table_enable') {
|
|
$dinner_table_id = preg_replace("/.*?_(\d+)$/", "$1", $key);
|
|
$not_to_delete[] = $dinner_table_id;
|
|
|
|
// Save or update
|
|
$table_data = ['name' => $value];
|
|
if ($this->dinner_table->save_value($table_data, $dinner_table_id)) {
|
|
$this->_clear_session_state(); // TODO: Remove hungarian notation.
|
|
}
|
|
}
|
|
}
|
|
|
|
// All tables not available in post will be deleted now
|
|
$deleted_tables = $this->dinner_table->get_all()->getResultArray();
|
|
|
|
foreach ($deleted_tables as $dinner_tables => $table) {
|
|
if (!in_array($table['dinner_table_id'], $not_to_delete)) {
|
|
$this->dinner_table->delete($table['dinner_table_id']);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->db->transComplete();
|
|
|
|
$success = $this->db->transStatus();
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Saves tax configuration. Used in app/Views/configs/tax_config.php
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveTax(): ResponseInterface
|
|
{
|
|
$default_tax_1_rate = $this->request->getPost('default_tax_1_rate');
|
|
$default_tax_2_rate = $this->request->getPost('default_tax_2_rate');
|
|
|
|
$batch_save_data = [
|
|
'default_tax_1_rate' => parse_tax(filter_var($default_tax_1_rate, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION)),
|
|
'default_tax_1_name' => $this->request->getPost('default_tax_1_name'),
|
|
'default_tax_2_rate' => parse_tax(filter_var($default_tax_2_rate, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION)),
|
|
'default_tax_2_name' => $this->request->getPost('default_tax_2_name'),
|
|
'tax_included' => $this->request->getPost('tax_included') != null,
|
|
'use_destination_based_tax' => $this->request->getPost('use_destination_based_tax') != null,
|
|
'default_tax_code' => $this->request->getPost('default_tax_code'),
|
|
'default_tax_category' => $this->request->getPost('default_tax_category'),
|
|
'default_tax_jurisdiction' => $this->request->getPost('default_tax_jurisdiction'),
|
|
'tax_id' => $this->request->getPost('tax_id', FILTER_SANITIZE_NUMBER_INT)
|
|
];
|
|
|
|
$success = $this->appconfig->batch_save($batch_save_data);
|
|
|
|
$message = lang('Config.saved_' . ($success ? '' : 'un') . 'successfully');
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => $message]);
|
|
}
|
|
|
|
/**
|
|
* Saves customer rewards configuration. Used in app/Views/configs/reward_config.php
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveRewards(): ResponseInterface
|
|
{
|
|
$this->db->transStart();
|
|
|
|
$customer_reward_enable = $this->request->getPost('customer_reward_enable') != null;
|
|
|
|
$this->appconfig->save(['customer_reward_enable' => $customer_reward_enable]);
|
|
|
|
if ($customer_reward_enable) {
|
|
$not_to_delete = [];
|
|
$array_save = [];
|
|
foreach ($this->request->getPost() as $key => $value) {
|
|
if (strstr($key, 'customer_reward') && $key != 'customer_reward_enable') {
|
|
$customer_reward_id = preg_replace("/.*?_(\d+)$/", "$1", $key);
|
|
$not_to_delete[] = $customer_reward_id;
|
|
$array_save[$customer_reward_id]['package_name'] = $value;
|
|
} elseif (str_contains($key, 'reward_points')) {
|
|
$customer_reward_id = preg_replace("/.*?_(\d+)$/", "$1", $key);
|
|
$array_save[$customer_reward_id]['points_percent'] = $value;
|
|
}
|
|
}
|
|
|
|
if (!empty($array_save)) {
|
|
foreach ($array_save as $key => $value) {
|
|
// Save or update
|
|
$package_data = ['package_name' => $value['package_name'], 'points_percent' => $value['points_percent']];
|
|
$this->customer_rewards->save_value($package_data, $key); // TODO: reflection exception
|
|
}
|
|
}
|
|
|
|
// All packages not available in post will be deleted now
|
|
$deleted_packages = $this->customer_rewards->get_all()->getResultArray();
|
|
|
|
foreach ($deleted_packages as $customer_rewards => $reward_category) {
|
|
if (!in_array($reward_category['package_id'], $not_to_delete)) {
|
|
$this->customer_rewards->delete($reward_category['package_id']);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->db->transComplete();
|
|
|
|
$success = $this->db->transStatus();
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Saves barcode configuration. Used in app/Views/configs/barcode_config.php
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveBarcode(): ResponseInterface
|
|
{
|
|
$batch_save_data = [
|
|
'barcode_type' => $this->request->getPost('barcode_type'),
|
|
'barcode_width' => $this->request->getPost('barcode_width', FILTER_SANITIZE_NUMBER_INT),
|
|
'barcode_height' => $this->request->getPost('barcode_height', FILTER_SANITIZE_NUMBER_INT),
|
|
'barcode_font' => $this->request->getPost('barcode_font'),
|
|
'barcode_font_size' => $this->request->getPost('barcode_font_size', FILTER_SANITIZE_NUMBER_INT),
|
|
'barcode_first_row' => $this->request->getPost('barcode_first_row'),
|
|
'barcode_second_row' => $this->request->getPost('barcode_second_row'),
|
|
'barcode_third_row' => $this->request->getPost('barcode_third_row'),
|
|
'barcode_num_in_row' => $this->request->getPost('barcode_num_in_row', FILTER_SANITIZE_NUMBER_INT),
|
|
'barcode_page_width' => $this->request->getPost('barcode_page_width', FILTER_SANITIZE_NUMBER_INT),
|
|
'barcode_page_cellspacing' => $this->request->getPost('barcode_page_cellspacing', FILTER_SANITIZE_NUMBER_INT),
|
|
'barcode_generate_if_empty' => $this->request->getPost('barcode_generate_if_empty') != null,
|
|
'allow_duplicate_barcodes' => $this->request->getPost('allow_duplicate_barcodes') != null,
|
|
'barcode_content' => $this->request->getPost('barcode_content'),
|
|
'barcode_formats' => json_encode($this->request->getPost('barcode_formats'))
|
|
];
|
|
|
|
$success = $this->appconfig->batch_save($batch_save_data);
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Saves receipt configuration. Used in app/Views/configs/receipt_config.php.
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveReceipt(): ResponseInterface
|
|
{
|
|
$batch_save_data = [
|
|
'receipt_template' => Sale_lib::isValidReceiptTemplate($this->request->getPost('receipt_template'))
|
|
? $this->request->getPost('receipt_template')
|
|
: 'receipt_default',
|
|
'receipt_font_size' => $this->request->getPost('receipt_font_size', FILTER_SANITIZE_NUMBER_INT),
|
|
'print_delay_autoreturn' => $this->request->getPost('print_delay_autoreturn', FILTER_SANITIZE_NUMBER_INT),
|
|
'email_receipt_check_behaviour' => $this->request->getPost('email_receipt_check_behaviour'),
|
|
'print_receipt_check_behaviour' => $this->request->getPost('print_receipt_check_behaviour'),
|
|
'receipt_show_company_name' => $this->request->getPost('receipt_show_company_name') != null,
|
|
'receipt_show_taxes' => $this->request->getPost('receipt_show_taxes') != null,
|
|
'receipt_show_tax_ind' => $this->request->getPost('receipt_show_tax_ind') != null,
|
|
'receipt_show_total_discount' => $this->request->getPost('receipt_show_total_discount') != null,
|
|
'receipt_show_description' => $this->request->getPost('receipt_show_description') != null,
|
|
'receipt_show_serialnumber' => $this->request->getPost('receipt_show_serialnumber') != null,
|
|
'print_silently' => $this->request->getPost('print_silently') != null,
|
|
'print_header' => $this->request->getPost('print_header') != null,
|
|
'print_footer' => $this->request->getPost('print_footer') != null,
|
|
'print_top_margin' => $this->request->getPost('print_top_margin', FILTER_SANITIZE_NUMBER_INT),
|
|
'print_left_margin' => $this->request->getPost('print_left_margin', FILTER_SANITIZE_NUMBER_INT),
|
|
'print_bottom_margin' => $this->request->getPost('print_bottom_margin', FILTER_SANITIZE_NUMBER_INT),
|
|
'print_right_margin' => $this->request->getPost('print_right_margin', FILTER_SANITIZE_NUMBER_INT)
|
|
];
|
|
|
|
$success = $this->appconfig->batch_save($batch_save_data);
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Saves keyboard shortcut bindings.
|
|
*
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveShortcuts(): ResponseInterface
|
|
{
|
|
$allowedShortcuts = array_keys($this->sale_lib->getKeyShortcutsOptions());
|
|
$currentShortcuts = $this->sale_lib->getKeyShortcuts();
|
|
$batchSaveData = [];
|
|
|
|
foreach ($currentShortcuts as $name => $shortcut) {
|
|
$postedValue = trim((string)$this->request->getPost('key_' . $name));
|
|
|
|
if (!in_array($postedValue, $allowedShortcuts, true)) {
|
|
$postedValue = $shortcut['value'];
|
|
}
|
|
|
|
$batchSaveData['key_' . $name] = $postedValue;
|
|
}
|
|
|
|
$duplicateValues = array_filter(array_count_values($batchSaveData), static fn(int $count): bool => $count > 1);
|
|
if (!empty($duplicateValues)) {
|
|
return $this->response->setJSON([
|
|
'success' => false,
|
|
'message' => lang('Config.shortcuts_duplicate_bindings')
|
|
]);
|
|
}
|
|
|
|
$success = $this->appconfig->batch_save($batchSaveData);
|
|
|
|
return $this->response->setJSON([
|
|
'success' => $success,
|
|
'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Saves invoice configuration. Used in app/Views/configs/invoice_config.php.
|
|
*
|
|
* @throws ReflectionException
|
|
* @return ResponseInterface
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postSaveInvoice(): ResponseInterface
|
|
{
|
|
$batch_save_data = [
|
|
'invoice_enable' => $this->request->getPost('invoice_enable') != null,
|
|
'sales_invoice_format' => $this->request->getPost('sales_invoice_format'),
|
|
'sales_quote_format' => $this->request->getPost('sales_quote_format'),
|
|
'recv_invoice_format' => $this->request->getPost('recv_invoice_format'),
|
|
'invoice_default_comments' => $this->request->getPost('invoice_default_comments'),
|
|
'invoice_email_message' => $this->request->getPost('invoice_email_message'),
|
|
'line_sequence' => $this->request->getPost('line_sequence'),
|
|
'last_used_invoice_number' => $this->request->getPost('last_used_invoice_number', FILTER_SANITIZE_NUMBER_INT),
|
|
'last_used_quote_number' => $this->request->getPost('last_used_quote_number', FILTER_SANITIZE_NUMBER_INT),
|
|
'quote_default_comments' => $this->request->getPost('quote_default_comments'),
|
|
'work_order_enable' => $this->request->getPost('work_order_enable') != null,
|
|
'work_order_format' => $this->request->getPost('work_order_format'),
|
|
'last_used_work_order_number' => $this->request->getPost('last_used_work_order_number', FILTER_SANITIZE_NUMBER_INT),
|
|
'invoice_type' => Sale_lib::isValidInvoiceType($this->request->getPost('invoice_type'))
|
|
? $this->request->getPost('invoice_type')
|
|
: 'invoice'
|
|
];
|
|
|
|
$success = $this->appconfig->batch_save($batch_save_data);
|
|
|
|
// Update the register mode with the latest change so that if the user
|
|
// switches immediately back to the register the mode reflects the change
|
|
if ($success) {
|
|
if ($this->config['invoice_enable']) {
|
|
$this->sale_lib->set_mode($this->config['default_register_mode']);
|
|
} else {
|
|
$this->sale_lib->set_mode('sale');
|
|
}
|
|
}
|
|
|
|
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
|
|
}
|
|
|
|
/**
|
|
* Removes the company logo from the database. Used in app/Views/configs/info_config.php.
|
|
*
|
|
* @return ResponseInterface
|
|
* @throws ReflectionException
|
|
* @noinspection PhpUnused
|
|
*/
|
|
public function postRemoveLogo(): ResponseInterface
|
|
{
|
|
$success = $this->appconfig->save(['company_logo' => '']);
|
|
|
|
return $this->response->setJSON(['success' => $success]);
|
|
}
|
|
|
|
/**
|
|
* Validates suggestions column configuration to prevent SQL injection.
|
|
*
|
|
* @param mixed $column The column value from POST
|
|
* @param string $fieldType Either 'first' or 'other' to determine default fallback
|
|
* @return string Validated column name
|
|
*/
|
|
private function validateSuggestionsColumn(mixed $column, string $fieldType): string
|
|
{
|
|
if (!is_string($column)) {
|
|
return $fieldType === 'first' ? 'name' : '';
|
|
}
|
|
|
|
$allowed = $fieldType === 'first'
|
|
? Item::ALLOWED_SUGGESTIONS_COLUMNS
|
|
: Item::ALLOWED_SUGGESTIONS_COLUMNS_WITH_EMPTY;
|
|
|
|
$fallback = $fieldType === 'first' ? 'name' : '';
|
|
|
|
return in_array($column, $allowed, true) ? $column : $fallback;
|
|
}
|
|
}
|