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
6 changed files with 127 additions and 18 deletions

View File

@@ -808,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

@@ -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

@@ -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

@@ -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']);
}
}