Compare commits

..

2 Commits

Author SHA1 Message Date
Ollama
6633bb36a8 fix: tax rate input locale handling - save path
The display fix (using (float) instead of to_tax_decimals()) was
correct but incomplete. The save path in Config.php also needed
fixing because parse_tax() misinterprets dot-decimal values from
type="number" inputs when locale uses comma as decimal separator.

Root cause: Browsers submit type="number" inputs as dot-decimal
(e.g., "5.5") regardless of locale. With comma-decimal locales
like de_DE, parse_tax() treats the dot as thousands separator,
causing 5.5 to be saved as 5.

Fix: Replace parse_tax() with direct (float) cast for these
inputs since type="number" already guarantees dot-decimal format.

Includes tests for tax rate handling with various decimal values.

Fixes #4553
2026-05-22 19:06:41 +02:00
Ollama
30d5ac4496 fix: tax rate inputs blank with comma-decimal locales
The to_tax_decimals() function returns locale-formatted values
(e.g. "18,00" for comma-decimal locales like fr_FR, de_DE).
Browsers reject comma-decimal values in <input type="number">
and render the field blank.

Use raw float value instead - PHP serializes floats with period
decimal regardless of locale. The parse_tax() on the save side
already handles locale-aware parsing, so round-tripping works
correctly.

Fixes #4553
Regression from commit 42ba39d29
2026-05-21 21:36:14 +02:00
14 changed files with 287 additions and 406 deletions

View File

@@ -106,54 +106,4 @@ class Encryption extends BaseConfig
* by CI3 Encryption default configuration.
*/
public string $cipher = 'AES-256-CTR';
/**
* Constructor - loads encryption key from fallback location if not set.
*
* This supports Docker/container environments where ROOTPATH/.env may be
* read-only or ephemeral. The fallback key file is stored in WRITEPATH/config/.
*/
public function __construct()
{
parent::__construct();
// If key not set from .env or environment, try WRITEPATH fallback
if (empty($this->key) || strlen($this->key) < 64) {
$fallbackKey = $this->loadKeyFromWritable();
if ($fallbackKey !== null) {
$this->key = $fallbackKey;
}
}
}
/**
* Loads encryption key from WRITEPATH/config/encryption.key.
*
* @return string|null The encryption key if found, null otherwise
*/
private function loadKeyFromWritable(): ?string
{
$keyFile = WRITEPATH . 'config' . DIRECTORY_SEPARATOR . 'encryption.key';
if (!file_exists($keyFile) || !is_readable($keyFile)) {
return null;
}
$content = file_get_contents($keyFile);
if ($content === false) {
return null;
}
$data = json_decode($content, true);
if (
!is_array($data)
|| !isset($data['key'])
|| !is_string($data['key'])
|| strlen($data['key']) < 64
) {
return null;
}
return $data['key'];
}
}

View File

@@ -17,9 +17,11 @@ 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;
@@ -28,6 +30,7 @@ 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;
@@ -59,6 +62,13 @@ class Config extends Secure_Controller
$this->tax = model(Tax::class);
$this->config = config(OSPOS::class)->settings;
$this->db = Database::connect();
helper('security');
if (check_encryption()) {
$this->encrypter = Services::encrypter();
} else {
log_message('alert', 'Error preparing encryption key');
}
}
/**
@@ -246,11 +256,25 @@ class Config extends Secure_Controller
// Integrations Related fields
$data['mailchimp'] = [];
$data['mailchimp']['api_key'] = decryptValue($this->config['mailchimp_api_key'] ?? null);
$data['mailchimp']['list_id'] = decryptValue($this->config['mailchimp_list_id'] ?? null);
if (check_encryption()) { // TODO: Hungarian notation
if (!isset($this->encrypter)) {
helper('security');
$this->encrypter = Services::encrypter();
}
if (checkEncryption()) {
removeBackup();
$data['mailchimp']['api_key'] = (isset($this->config['mailchimp_api_key']) && !empty($this->config['mailchimp_api_key']))
? $this->encrypter->decrypt($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()
remove_backup();
} else {
$data['mailchimp']['api_key'] = '';
$data['mailchimp']['list_id'] = '';
}
$data['mailchimp']['lists'] = $this->_mailchimp();
@@ -488,23 +512,15 @@ class Config extends Secure_Controller
public function postSaveEmail(): ResponseInterface
{
$password = '';
$passwordInput = $this->request->getPost('smtp_pass');
if (!empty($passwordInput)) {
$password = encryptValue($passwordInput);
if (empty($password)) {
log_message('error', 'SMTP password encryption failed - credentials not saved');
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
if (check_encryption() && !empty($this->request->getPost('smtp_pass'))) {
$password = $this->encrypter->encrypt($this->request->getPost('smtp_pass'));
}
$protocol = $this->request->getPost('protocol');
$mailpath = $this->request->getPost('mailpath');
// Validate mailpath: required for sendmail, optional for others but must be safe if provided
$isMailpathRequired = ($protocol === 'sendmail');
$isMailpathProvided = !empty($mailpath);
$isMailpathValid = $isMailpathProvided && preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $mailpath);
@@ -512,7 +528,7 @@ class Config extends Secure_Controller
if (($isMailpathRequired && !$isMailpathProvided) || ($isMailpathProvided && !$isMailpathValid)) {
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.mailpath_invalid'),
'message' => lang('Config.mailpath_invalid')
]);
}
@@ -524,7 +540,7 @@ class Config extends Secure_Controller
'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'),
'smtp_crypto' => $this->request->getPost('smtp_crypto')
];
$success = $this->appconfig->batch_save($batch_save_data);
@@ -542,25 +558,16 @@ class Config extends Secure_Controller
public function postSaveMessage(): ResponseInterface
{
$password = '';
$passwordInput = $this->request->getPost('msg_pwd');
if (!empty($passwordInput)) {
$password = encryptValue($passwordInput);
if (empty($password)) {
log_message('error', 'SMS password encryption failed');
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
if (check_encryption() && !empty($this->request->getPost('msg_pwd'))) {
$password = $this->encrypter->encrypt($this->request->getPost('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'),
'msg_src' => $this->request->getPost('msg_src')
];
$success = $this->appconfig->batch_save($batch_save_data);
@@ -616,38 +623,24 @@ class Config extends Secure_Controller
*/
public function postSaveMailchimp(): ResponseInterface
{
$apiKey = '';
$listId = '';
$api_key = '';
$list_id = '';
$apiKeyInput = $this->request->getPost('mailchimp_api_key');
if (!empty($apiKeyInput)) {
$apiKey = encryptValue($apiKeyInput);
if (empty($apiKey)) {
log_message('error', 'Mailchimp API key encryption failed');
if (check_encryption()) {
$api_key_unencrypted = $this->request->getPost('mailchimp_api_key');
if (!empty($api_key_unencrypted)) {
$api_key = $this->encrypter->encrypt($api_key_unencrypted);
}
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
$list_id_unencrypted = $this->request->getPost('mailchimp_list_id');
if (!empty($list_id_unencrypted)) {
$list_id = $this->encrypter->encrypt($list_id_unencrypted);
}
}
$listIdInput = $this->request->getPost('mailchimp_list_id');
if (!empty($listIdInput)) {
$listId = encryptValue($listIdInput);
if (empty($listId)) {
log_message('error', 'Mailchimp list ID encryption failed');
$batch_save_data = ['mailchimp_api_key' => $api_key, 'mailchimp_list_id' => $list_id];
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
}
$batchSaveData = ['mailchimp_api_key' => $apiKey, 'mailchimp_list_id' => $listId];
$success = $this->appconfig->batch_save($batchSaveData);
$success = $this->appconfig->batch_save($batch_save_data);
return $this->response->setJSON(['success' => $success, 'message' => lang('Config.saved_' . ($success ? '' : 'un') . 'successfully')]);
}
@@ -815,10 +808,14 @@ class Config extends Secure_Controller
$default_tax_1_rate = $this->request->getPost('default_tax_1_rate');
$default_tax_2_rate = $this->request->getPost('default_tax_2_rate');
// Note: parse_tax() is not used here because these inputs use type="number"
// which always submits dot-decimal values regardless of locale. Using parse_tax()
// with a comma-decimal locale (e.g., de_DE) would incorrectly interpret the dot
// as a thousands separator, causing 5.5 to be saved as 5.
$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_rate' => (float) 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_rate' => (float) 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,

View File

@@ -31,7 +31,13 @@ class Customers extends Persons
$this->tax_code = model(Tax_code::class);
$this->config = config(OSPOS::class)->settings;
$this->_list_id = decryptValue($this->config['mailchimp_list_id'] ?? null);
$encrypter = Services::encrypter();
if (!empty($this->config['mailchimp_list_id'])) {
$this->_list_id = $encrypter->decrypt($this->config['mailchimp_list_id']);
} else {
$this->_list_id = '';
}
}
/**

View File

@@ -161,7 +161,7 @@ class Sales extends Secure_Controller
'only_bank_transfer'=> false,
'only_wallet' => false,
'only_invoices' => $this->config['invoice_enable'] && $this->request->getGet('only_invoices', FILTER_SANITIZE_NUMBER_INT),
'is_valid_receipt' => $this->sale->isValidReceipt($search)
'is_valid_receipt' => $this->sale->is_valid_receipt($search)
];
// Check if any filter is set in the multiselect dropdown
@@ -198,7 +198,7 @@ class Sales extends Secure_Controller
? $this->request->getGet('term')
: null;
if ($this->sale_lib->get_mode() == 'return' && $this->sale->isValidReceipt($receipt)) {
if ($this->sale_lib->get_mode() == 'return' && $this->sale->is_valid_receipt($receipt)) {
// If a valid receipt or invoice was found the search term will be replaced with a receipt number (POS #)
$suggestions[] = $receipt;
}
@@ -525,7 +525,7 @@ class Sales extends Secure_Controller
$quantity = ($mode == 'return') ? -$quantity : $quantity;
$item_location = $this->sale_lib->get_sale_location();
if ($mode == 'return' && $this->sale->isValidReceipt($item_id_or_number_or_item_kit_or_receipt)) {
if ($mode == 'return' && $this->sale->is_valid_receipt($item_id_or_number_or_item_kit_or_receipt)) {
$this->sale_lib->return_entire_sale($item_id_or_number_or_item_kit_or_receipt);
} elseif ($this->item_kit->is_valid_item_kit($item_id_or_number_or_item_kit_or_receipt)) {
// Add kit item to order if one is assigned

View File

@@ -33,10 +33,10 @@ class Convert_to_ci4 extends Migration
if (!empty(config('Encryption')->key)) {
$this->convert_ci3_encrypted_data();
} else {
checkEncryption();
check_encryption();
}
removeBackup();
remove_backup();
}
/**
@@ -66,15 +66,15 @@ class Convert_to_ci4 extends Migration
$decrypted_data = $this->decrypt_ci3_data($ci3_encrypted_data);
checkEncryption();
check_encryption();
try {
$ci4_encrypted_data = $this->encrypt_data($decrypted_data);
$success = empty(array_diff_assoc($decrypted_data, $this->decrypt_data($ci4_encrypted_data)));
if (!$success) {
abortEncryptionConversion();
removeBackup();
abort_encryption_conversion();
remove_backup();
throw new RedirectException('login');
}

View File

@@ -1,304 +1,96 @@
<?php
use CodeIgniter\Encryption\Encryption;
use CodeIgniter\Encryption\Exceptions\EncryptionException;
use Config\Services;
/**
* Checks and initializes encryption key.
*
* This function ensures a valid encryption key exists for the application.
* It tries multiple storage locations to support different deployment scenarios:
* 1. ROOTPATH/.env - Standard location for non-containerized deployments
* 2. WRITEPATH/config/encryption.key - Fallback for Docker/container environments where .env is read-only
*
* @return bool True if encryption key is available, false if key generation/persistence failed
* @return bool
*/
function checkEncryption(): bool
function check_encryption(): bool
{
$oldKey = config('Encryption')->key;
$old_key = config('Encryption')->key;
if (!empty($oldKey) && strlen($oldKey) >= 64) {
return true;
}
if ((empty($old_key)) || (strlen($old_key) < 64)) {
$encryption = new Encryption();
$key = bin2hex($encryption->createKey());
config('Encryption')->key = $key;
$encryption = new Encryption();
$key = bin2hex($encryption->createKey());
config('Encryption')->key = $key;
$config_path = ROOTPATH . '.env';
$backup_path = WRITEPATH . '/backup/.env.bak';
$backup_folder = WRITEPATH . '/backup';
$envPersisted = writeEncryptionKeyToEnv($key, $oldKey);
$writablePersisted = writeEncryptionKeyToWritable($key, $oldKey);
$persisted = $envPersisted || $writablePersisted;
if ($persisted) {
log_message('info', 'Encryption key initialized successfully');
} else {
log_message('error', 'Failed to persist encryption key to any location. Encryption may not survive container restarts.');
}
return $persisted;
}
/**
* Writes encryption key to ROOTPATH/.env file.
*
* @param string $key The new encryption key (hex-encoded)
* @param string|null $oldKey The previous key to preserve for key rotation
*
* @return bool True if key was written successfully, false otherwise
*/
function writeEncryptionKeyToEnv(string $key, ?string $oldKey = null): bool
{
$configPath = ROOTPATH . '.env';
$backupPath = WRITEPATH . 'backup' . DIRECTORY_SEPARATOR . '.env.bak';
$backupFolder = WRITEPATH . 'backup';
if (!file_exists($backupFolder)) {
if (!@mkdir($backupFolder, 0750, true)) {
log_message('debug', 'Could not create backup directory');
if (!file_exists($backup_folder)) {
@mkdir($backup_folder, 0750, true);
}
}
if (!file_exists($configPath)) {
$examplePath = ROOTPATH . '.env.example';
if (file_exists($examplePath)) {
if (!@copy($examplePath, $configPath)) {
log_message('debug', 'Could not copy .env.example to .env');
if (!file_exists($config_path)) {
$example_path = ROOTPATH . '.env.example';
if (file_exists($example_path)) {
@copy($example_path, $config_path);
} else {
@file_put_contents($config_path, "# OSPOS Configuration\n\n");
}
} else {
if (!@file_put_contents($configPath, "# OSPOS Configuration\n\n") !== false) {
log_message('debug', 'Could not create .env file');
@chmod($config_path, 0640);
}
if (file_exists($config_path)) {
@copy($config_path, $backup_path);
@chmod($backup_path, 0640);
@chmod($config_path, 0640);
$config_file = file_get_contents($config_path);
if (strpos($config_file, 'encryption.key') !== false) {
$config_file = preg_replace("/(encryption\.key.*=.*)('.*')/", "$1'$key'", $config_file);
} else {
$config_file .= "\nencryption.key = '$key'\n";
}
}
@chmod($configPath, 0640);
}
if (!is_writable($configPath)) {
log_message('debug', '.env file is not writable');
return false;
}
if (!empty($old_key)) {
$old_line = "# encryption.key = '$old_key' REMOVE IF UNNEEDED\r\n";
$insertion_point = stripos($config_file, 'encryption.key');
if ($insertion_point !== false) {
$config_file = substr_replace($config_file, $old_line, $insertion_point, 0);
}
}
if (file_exists($configPath)) {
@copy($configPath, $backupPath);
@chmod($backupPath, 0640);
}
@file_put_contents($config_path, $config_file);
@chmod($config_path, 0640);
$configFile = file_get_contents($configPath);
if ($configFile === false) {
log_message('debug', 'Could not read .env file');
return false;
}
if (strpos($configFile, 'encryption.key') !== false) {
$configFile = preg_replace("/(encryption\.key.*=.*)(['\"])([^'\"]*)\\2/", "$1'$key'", $configFile);
} else {
$configFile .= "\nencryption.key = '$key'\n";
}
if (!empty($oldKey)) {
$oldLine = "# encryption.key = '$oldKey' REMOVE IF UNNEEDED\r\n";
$insertionPoint = stripos($configFile, 'encryption.key');
if ($insertionPoint !== false) {
$configFile = substr_replace($configFile, $oldLine, $insertionPoint, 0);
log_message('info', "Updated encryption key in $config_path");
}
}
$result = file_put_contents($configPath, $configFile);
if ($result === false) {
log_message('debug', 'Could not write to .env file');
return false;
}
@chmod($configPath, 0640);
log_message('info', "Updated encryption key in $configPath");
return true;
}
/**
* Writes encryption key to WRITEPATH/config/encryption.key file.
*
* This is the fallback location for Docker/container environments where
* the ROOTPATH/.env file may be read-only or ephemeral.
*
* @param string $key The new encryption key (hex-encoded)
* @param string|null $oldKey The previous key to preserve for key rotation
*
* @return bool True if key was written successfully, false otherwise
*/
function writeEncryptionKeyToWritable(string $key, ?string $oldKey = null): bool
{
$keyFile = WRITEPATH . 'config' . DIRECTORY_SEPARATOR . 'encryption.key';
$keyDir = dirname($keyFile);
if (!is_dir($keyDir)) {
if (!@mkdir($keyDir, 0750, true)) {
log_message('error', 'Could not create config directory: ' . $keyDir);
return false;
}
}
if (!is_writable($keyDir)) {
log_message('error', 'Config directory is not writable: ' . $keyDir);
return false;
}
$data = [
'key' => $key,
'previous_keys' => [],
'generated_at' => date('c'),
'generated_by' => 'checkEncryption()',
];
if (!empty($oldKey)) {
$data['previous_keys'][] = $oldKey;
}
$content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$result = file_put_contents($keyFile, $content);
if ($result === false) {
log_message('error', 'Could not write encryption key file');
return false;
}
@chmod($keyFile, 0640);
log_message('info', "Stored encryption key in $keyFile");
return true;
}
/**
* Loads encryption key from WRITEPATH/config/encryption.key file.
*
* This is the fallback key loader for Docker/container environments.
*
* @return string|null The encryption key if found, null otherwise
*/
function loadEncryptionKeyFromWritable(): ?string
{
$keyFile = WRITEPATH . 'config' . DIRECTORY_SEPARATOR . 'encryption.key';
if (!file_exists($keyFile)) {
return null;
}
if (!is_readable($keyFile)) {
log_message('error', 'Encryption key file exists but is not readable: ' . $keyFile);
return null;
}
$content = file_get_contents($keyFile);
if ($content === false) {
log_message('error', 'Could not read encryption key file');
return null;
}
$data = json_decode($content, true);
if (!is_array($data) || empty($data['key'])) {
log_message('error', 'Encryption key file has invalid format');
return null;
}
log_message('info', 'Loaded encryption key from WRITEPATH config');
return $data['key'];
}
/**
* Restores .env from backup (used by migration rollback).
*
* @return void
*/
function abortEncryptionConversion(): void
function abort_encryption_conversion(): void
{
$configPath = ROOTPATH . '.env';
$backupPath = WRITEPATH . '/backup/.env.bak';
$config_path = ROOTPATH . '.env';
$backup_path = WRITEPATH . '/backup/.env.bak';
if (!file_exists($backupPath)) {
if (!file_exists($backup_path)) {
return;
}
@chmod($configPath, 0640);
$configFile = file_get_contents($backupPath);
@file_put_contents($configPath, $configFile);
log_message('info', "Restored $configPath from backup");
@chmod($config_path, 0640);
$config_file = file_get_contents($backup_path);
@file_put_contents($config_path, $config_file);
log_message('info', "Restored $config_path from backup");
}
/**
* Removes backup file (used after successful migration).
*
* @return void
*/
function removeBackup(): void
function remove_backup(): void
{
$backupPath = WRITEPATH . '/backup/.env.bak';
if (file_exists($backupPath)) {
unlink($backupPath);
$backup_path = WRITEPATH . '/backup/.env.bak';
if (!file_exists($backup_path)) {
return;
}
@unlink($backup_path);
log_message('info', "Removed $backup_path");
}
/**
* Decrypts an encrypted value with proper error handling.
*
* This function provides a consistent decryption pattern across the codebase,
* handling cases where encryption key may not be available or decryption fails.
*
* @param string|null $encryptedValue The encrypted value to decrypt
* @param string $default Default value to return if decryption fails
*
* @return string The decrypted value, or default if decryption fails
*/
function decryptValue(?string $encryptedValue, string $default = ''): string
{
if (empty($encryptedValue)) {
return $default;
}
if (!checkEncryption()) {
log_message('warning', 'Cannot decrypt value: encryption key not available');
return $default;
}
try {
$encrypter = Services::encrypter();
return $encrypter->decrypt($encryptedValue);
} catch (EncryptionException $e) {
log_message('error', 'Decryption failed: ' . $e->getMessage());
return $default;
}
}
/**
* Encrypts a value with proper error handling.
*
* This function provides a consistent encryption pattern across the codebase,
* handling cases where encryption key may not be available.
*
* @param string|null $value The value to encrypt
* @param bool $require Whether encryption is required (returns empty string on failure)
* If false, returns original value on failure
*
* @return string The encrypted value, or empty string/original value if encryption fails
*/
function encryptValue(?string $value, bool $require = true): string
{
if ($value === null || $value === '') {
return '';
}
if (!checkEncryption()) {
log_message('error', 'Cannot encrypt value: encryption key not available');
return $require ? '' : $value;
}
try {
$encrypter = Services::encrypter();
return $encrypter->encrypt($value);
} catch (EncryptionException $e) {
log_message('error', 'Encryption failed: ' . $e->getMessage());
return $require ? '' : $value;
}
}

View File

@@ -122,7 +122,6 @@ return [
"email_smtp_port" => "SMTP Port",
"email_smtp_timeout" => "SMTP Timeout (s)",
"email_smtp_user" => "SMTP Username",
"encryption_failed" => "Failed to encrypt data. Please check encryption configuration.",
"enable_avatar" => "",
"enable_avatar_tooltip" => "",
"enable_dropdown_tooltip" => "",

View File

@@ -3,7 +3,11 @@
namespace app\Libraries;
use CodeIgniter\Email\Email;
use CodeIgniter\Encryption\Encryption;
use CodeIgniter\Encryption\EncrypterInterface;
use CodeIgniter\Encryption\Exceptions\EncryptionException;
use Config\OSPOS;
use Config\Services;
/**
@@ -22,9 +26,21 @@ class Email_lib
$this->email = new Email();
$this->config = config(OSPOS::class)->settings;
$smtpPass = decryptValue($this->config['smtp_pass'] ?? null);
$encrypter = Services::encrypter();
$emailConfig = [
$smtp_pass = $this->config['smtp_pass'];
if (!empty($smtp_pass) && check_encryption()) {
try {
$smtp_pass = $encrypter->decrypt($smtp_pass);
} catch (\EncryptionException $e) {
// Decryption failed, use the original value
log_message('error', 'SMTP password decryption failed: ' . $e->getMessage());
$smtp_pass = '';
}
}
$email_config = [
'mailType' => 'html',
'userAgent' => 'OSPOS',
'validate' => true,
@@ -32,12 +48,12 @@ class Email_lib
'mailPath' => $this->config['mailpath'],
'SMTPHost' => $this->config['smtp_host'],
'SMTPUser' => $this->config['smtp_user'],
'SMTPPass' => $smtpPass,
'SMTPPass' => $smtp_pass,
'SMTPPort' => (int)$this->config['smtp_port'],
'SMTPTimeout' => (int)$this->config['smtp_timeout'],
'SMTPCrypto' => $this->config['smtp_crypto'],
'SMTPCrypto' => $this->config['smtp_crypto']
];
$this->email->initialize($emailConfig);
$this->email->initialize($email_config);
}
/**

View File

@@ -25,7 +25,7 @@ class MY_Migration extends MigrationRunner
public function get_latest_migration(): int
{
$migrations = $this->findMigrations();
return (int) basename(end($migrations)->version);
return basename(end($migrations)->version);
}
/**
@@ -41,7 +41,7 @@ class MY_Migration extends MigrationRunner
$builder = $db->table('migrations');
$builder->select('version')->orderBy('version', 'DESC')->limit(1);
$result = $builder->get()->getRow();
return $result ? (int) $result->version : 0;
return $result ? $result->version : 0;
}
} catch (\Exception $e) {
// Database not available yet (e.g. fresh install before schema).

View File

@@ -2,7 +2,9 @@
namespace app\Libraries;
use CodeIgniter\Encryption\EncrypterInterface;
use Config\OSPOS;
use Config\Services;
/**
* MailChimp API v3 REST client Connector
@@ -12,6 +14,8 @@ use Config\OSPOS;
* Inspired by the work of:
* - Rajitha Bandara: https://github.com/rajitha-bandara/ci-mailchimp-v3-rest-client
* - Stefan Ashwell: https://github.com/stef686/codeigniter-mailchimp-api-v3
*
* @property encrypterinterface encrypter
*/
class MailchimpConnector
{
@@ -36,19 +40,23 @@ class MailchimpConnector
{
$config = config(OSPOS::class)->settings;
$mailchimp_api_key = $config['mailchimp_api_key'] ?? '';
$encrypter = Services::encrypter();
$mailchimp_api_key = (isset($this->config['mailchimp_api_key']) && !empty($this->config['mailchimp_api_key']))
? $this->config['mailchimp_api_key']
: '';
if (!empty($mailchimp_api_key)) {
$this->_api_key = empty($api_key)
? decryptValue($mailchimp_api_key)
: $api_key;
? $encrypter->decrypt($mailchimp_api_key) // TODO: Hungarian notation
: $api_key; // TODO: Hungarian notation
}
if (!empty($this->_api_key)) {
if (!empty($this->_api_key)) { // TODO: Hungarian notation
// Replace <dc> with correct datacenter obtained from the last part of the api key
$strings = explode('-', $this->_api_key);
$strings = explode('-', $this->_api_key); // TODO: Hungarian notation
if (is_array($strings) && !empty($strings[1])) {
$this->_api_endpoint = str_replace('<dc>', $strings[1], $this->_api_endpoint);
$this->_api_endpoint = str_replace('<dc>', $strings[1], $this->_api_endpoint); // TODO: Hungarian notation
}
}
}

View File

@@ -2,7 +2,10 @@
namespace app\Libraries;
use CodeIgniter\Encryption\Encryption;
use CodeIgniter\Encryption\EncrypterInterface;
use Config\OSPOS;
use Config\Services;
/**
@@ -21,7 +24,12 @@ class Sms_lib
{
$config = config(OSPOS::class)->settings;
$password = decryptValue($config['msg_pwd'] ?? null);
$encrypter = Services::encrypter();
$password = $config['msg_pwd'];
if (!empty($password)) {
$password = $encrypter->decrypt($password);
}
$username = $config['msg_uid'];
$originator = $config['msg_src'];

View File

@@ -327,7 +327,7 @@ class Sale extends Model
{
$suggestions = [];
if (!$this->isValidReceipt($search)) {
if (!$this->is_valid_receipt($search)) {
$builder = $this->db->table('sales');
$builder->distinct()->select('first_name, last_name');
$builder->join('people', 'people.person_id = sales.customer_id');
@@ -408,21 +408,21 @@ class Sale extends Model
/**
* Checks if valid receipt
*/
public function isValidReceipt(string|null &$receiptSaleId): bool // TODO: like the others, maybe this should be an array rather than a delimited string... either that or the parameter name needs to be changed. $receipt_sale_id implies that it's an int.
public function is_valid_receipt(string|null &$receipt_sale_id): bool // TODO: like the others, maybe this should be an array rather than a delimited string... either that or the parameter name needs to be changed. $receipt_sale_id implies that it's an int.
{
$config = config(OSPOS::class)->settings;
if (!empty($receiptSaleId)) {
if (!empty($receipt_sale_id)) {
// POS #
$pieces = explode(' ', trim($receiptSaleId));
$pieces = explode(' ', $receipt_sale_id);
if (count($pieces) == 2 && strtoupper($pieces[0]) === 'POS' && ctype_digit($pieces[1])) {
return $this->exists((int)$pieces[1]);
if (count($pieces) == 2 && preg_match('/(POS)/i', $pieces[0])) {
return $this->exists($pieces[1]);
} elseif ($config['invoice_enable']) {
$saleInfo = $this->get_sale_by_invoice_number($receiptSaleId);
$sale_info = $this->get_sale_by_invoice_number($receipt_sale_id);
if ($saleInfo->getNumRows() > 0) {
$receiptSaleId = 'POS ' . $saleInfo->getRow()->sale_id;
if ($sale_info->getNumRows() > 0) {
$receipt_sale_id = 'POS ' . $sale_info->getRow()->sale_id;
return true;
}

View File

@@ -58,7 +58,7 @@
'name' => 'default_tax_1_rate',
'id' => 'default_tax_1_rate',
'class' => 'form-control input-sm',
'value' => to_tax_decimals($config['default_tax_1_rate'])
'value' => $config['default_tax_1_rate'] !== '' ? (float) $config['default_tax_1_rate'] : ''
]) ?>
<span class="input-group-addon input-sm">%</span>
</div>
@@ -83,7 +83,7 @@
'name' => 'default_tax_2_rate',
'id' => 'default_tax_2_rate',
'class' => 'form-control input-sm',
'value' => to_tax_decimals($config['default_tax_2_rate'])
'value' => $config['default_tax_2_rate'] !== '' ? (float) $config['default_tax_2_rate'] : ''
]) ?>
<span class="input-group-addon input-sm">%</span>
</div>

View File

@@ -6,6 +6,7 @@ use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Config\Services;
use App\Models\Appconfig;
class ConfigTest extends CIUnitTestCase
{
@@ -218,4 +219,108 @@ class ConfigTest extends CIUnitTestCase
$result = json_decode($response->getJSON(), true);
$this->assertFalse($result['success']);
}
// ========== Tax Rate Locale Tests ==========
// These tests verify that tax rate inputs (type="number") work correctly
// regardless of locale settings. Browsers always submit type="number" inputs
// as dot-decimal values, so the server must handle them correctly without
// using locale-aware parse_tax() which would misinterpret the dot.
public function testTaxRate_SavesDotDecimalValueCorrectly(): void
{
$this->resetSession();
// type="number" inputs always submit dot-decimal "5.5", not comma-decimal "5,5"
$response = $this->post('/config/saveTax', [
'default_tax_1_rate' => '5.5',
'default_tax_1_name' => 'Tax 1',
'tax_included' => '0',
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
// Verify the value was saved correctly as 5.5, not truncated to 5
$config = model(Appconfig::class);
$savedRate = $config->get_value('default_tax_1_rate');
$this->assertEquals(5.5, (float) $savedRate, 'Tax rate should be saved as 5.5, not truncated to 5');
}
public function testTaxRate_SavesIntegerValueCorrectly(): void
{
$this->resetSession();
$response = $this->post('/config/saveTax', [
'default_tax_1_rate' => '18',
'default_tax_1_name' => 'VAT',
'tax_included' => '0',
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
$config = model(Appconfig::class);
$savedRate = $config->get_value('default_tax_1_rate');
$this->assertEquals(18.0, (float) $savedRate, 'Tax rate should be saved as 18');
}
public function testTaxRate_SavesHighPrecisionDecimal(): void
{
$this->resetSession();
$response = $this->post('/config/saveTax', [
'default_tax_1_rate' => '8.25',
'default_tax_1_name' => 'Sales Tax',
'tax_included' => '0',
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
$config = model(Appconfig::class);
$savedRate = $config->get_value('default_tax_1_rate');
$this->assertEquals(8.25, (float) $savedRate, 'Tax rate should preserve decimal precision');
}
public function testTaxRate_BothTaxRatesSavedCorrectly(): void
{
$this->resetSession();
$response = $this->post('/config/saveTax', [
'default_tax_1_rate' => '10.5',
'default_tax_1_name' => 'State Tax',
'default_tax_2_rate' => '5.25',
'default_tax_2_name' => 'Local Tax',
'tax_included' => '0',
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
$config = model(Appconfig::class);
$savedRate1 = $config->get_value('default_tax_1_rate');
$savedRate2 = $config->get_value('default_tax_2_rate');
$this->assertEquals(10.5, (float) $savedRate1, 'Tax 1 rate should be 10.5');
$this->assertEquals(5.25, (float) $savedRate2, 'Tax 2 rate should be 5.25');
}
public function testTaxRate_HandlesEmptyString(): void
{
$this->resetSession();
$response = $this->post('/config/saveTax', [
'default_tax_1_rate' => '',
'default_tax_1_name' => 'Tax 1',
'tax_included' => '0',
]);
$response->assertStatus(200);
$result = json_decode($response->getJSON(), true);
$this->assertTrue($result['success']);
}
}