Files
opensourcepos/app/Models/Tax.php
T
jekkosandobjecttothis 9fef0bd88f fix(models): handle query() returning false in result-consuming methods (#4734)
* fix(models): handle query() returning false in result-consuming methods

As of CI 4.1.2, Connection::query() returns false when a query fails
(previously an empty result object). Guard the model/helper methods that
consume the result so a failed query degrades to an empty result instead
of throwing "Call to member function getResultArray() on false".

- Tax::get_taxes()
- Sale::get_all_suspended()
- Reports\Inventory_low::getData()
- migration_helper: dropAllForeignKeyConstraints(), indexExists(),
  primaryKeyExists()

Adds QueryFalseGuardTest forcing query() to return false. Closes #3634.

* test(query-false): mock concrete driver instead of abstract BaseConnection

onlyMethods() does not stub the 16 abstract methods on BaseConnection, so
mocking it produced an invalid double. Mock the concrete driver class
(resolved from the live test connection) instead.

* fix(3634): fail loudly when migration metadata queries return false

Model read methods degrade to an empty result on a failed query, but the
migration metadata helpers (indexExists, primaryKeyExists,
dropAllForeignKeyConstraints) must not mistake a failed query for 'not
found' and proceed with schema changes. They now throw a DatabaseException
so the migration aborts loudly.

- Add an optional injectable BaseConnection param for testability.
- Add missing docstrings (docstring-coverage check).
- Add regression tests asserting the helpers throw on a failed query.

* fix(3634): use supplied connection for FK drop; stub error() in tests

Address CodeRabbit review: dropAllForeignKeyConstraints now builds its
Forge from the supplied connection (consistent with the lookup) instead of
always the default one. Test mock also stubs error() to avoid a warning on
the constructor-disabled connection.

* fix(3634): revert FK drop to Database::forge() (driver-specific forge)

new Forge($db) creates the base Forge which does not implement
dropForeignKey for the driver ('This feature is not available'), breaking
migrations. Restore Database::forge(), which loads the driver-specific
Forge via the factory. The optional connection param is retained solely
for testing the fail-loud behavior on the lookup path.

* test(3634): add regression test for Inventory_low::getData query-failure guard

Covers the low-stock report's degrade-to-empty behavior on a failed query,
addressing the CodeRabbit note that it lacked a focused test.

* test(3634): adopt test{Method}_{Purpose} naming convention

Align QueryFalseGuardTest method names with the convention from #4730
(testGetTaxes_ReturnsEmptyArrayWhenQueryFails, etc.), per review.

* test: split QueryFalseGuardTest into per-component test files

Apply CodeRabbit review: extract the shared failing-connection helpers
into tests/Support/FailedQueryConnectionTrait.php and move each test to
its component's test file (TaxTest, SaleTest, Inventory_lowTest,
migration_helperTest). Use fresh model instances instead of the shared
model() singleton so the injected failing connection does not leak into
other tests.

* style(tests): remove redundant docblocks from query-failure regression tests

---------

Co-authored-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
2026-10-08 22:30:16 +02:00

318 lines
10 KiB
PHP

<?php
namespace App\Models;
use CodeIgniter\Database\ResultInterface;
use CodeIgniter\Model;
use stdClass;
/**
* Tax class
*/
class Tax extends Model
{
protected $table = 'tax_rates';
protected $primaryKey = 'tax_rate_id';
protected $useAutoIncrement = true;
protected $useSoftDeletes = false;
protected $allowedFields = [
'rate_tax_code_id',
'rate_tax_category_id',
'rate_jurisdiction_id',
'tax_rate',
'tax_rounding_code'
];
/**
* Determines if a given row is on file
*/
public function exists(int $tax_rate_id): bool
{
$builder = $this->db->table('tax_rates');
$builder->where('tax_rate_id', $tax_rate_id);
return ($builder->get()->getNumRows() == 1); // TODO: ===
}
/**
* Gets total of rows
*/
public function get_total_rows(): int
{
$builder = $this->db->table('tax_rates');
return $builder->countAllResults();
}
/**
* Gets list of tax rates that are assigned to a particular tax category
*/
public function get_tax_category_usage(int $tax_category_id): int
{
$builder = $this->db->table('tax_rates');
$builder->where('rate_tax_category_id', $tax_category_id);
return $builder->countAllResults();
}
/**
* Gets the row for a particular id
*/
public function get_info(int $tax_rate_id): object
{
$builder = $this->db->table('tax_rates');
$builder->select('tax_rate_id');
$builder->select('rate_tax_code_id');
$builder->select('tax_code');
$builder->select('tax_code_name');
$builder->select('rate_jurisdiction_id');
$builder->select('jurisdiction_name');
$builder->select('rate_tax_category_id');
$builder->select('tax_category');
$builder->select('tax_rate');
$builder->select('tax_rounding_code');
$builder->join(
'tax_codes',
'rate_tax_code_id = tax_code_id',
'LEFT'
);
$builder->join(
'tax_categories',
'rate_tax_category_id = tax_category_id',
'LEFT'
);
$builder->join(
'tax_jurisdictions',
'rate_jurisdiction_id = jurisdiction_id',
'LEFT'
);
$builder->where('tax_rate_id', $tax_rate_id);
$query = $builder->get();
if ($query->getNumRows() == 1) { // TODO: probably should use === here since getNumRows() returns an int.
return $query->getRow();
} else {
// Get empty base parent object
$tax_rate_obj = new stdClass();
$tax_rate_obj->tax_rate_id = null;
$tax_rate_obj->rate_tax_code_id = null;
$tax_rate_obj->tax_code = '';
$tax_rate_obj->tax_code_name = '';
$tax_rate_obj->rate_tax_category_id = null;
$tax_rate_obj->tax_category = '';
$tax_rate_obj->tax_rate = 0.0;
$tax_rate_obj->tax_rounding_code = '0';
$tax_rate_obj->rate_jurisdiction_id = null;
$tax_rate_obj->jurisdiction_name = '';
return $tax_rate_obj;
}
}
/**
* Get taxes to be collected for a given tax code
*/
public function get_taxes(int $tax_code_id, int $tax_category_id): array
{
$sql = 'select tax_rate_id, rate_tax_code_id, tax_code, tax_code_name, tax_type, cascade_sequence, rate_tax_category_id, tax_category,
rate_jurisdiction_id, jurisdiction_name, tax_group, tax_rate, tax_rounding_code,tax_categories.tax_group_sequence + tax_jurisdictions.tax_group_sequence as tax_group_sequence
from ' . $this->db->prefixTable('tax_rates') . '
left outer join ' . $this->db->prefixTable('tax_codes') . ' on rate_tax_code_id = tax_code_id
left outer join ' . $this->db->prefixTable('tax_categories') . ' as tax_categories on rate_tax_category_id = tax_category_id
left outer join ' . $this->db->prefixTable('tax_jurisdictions') . ' as tax_jurisdictions on rate_jurisdiction_id = jurisdiction_id
where rate_tax_code_id = ' . $this->db->escape($tax_code_id) . ' and rate_tax_category_id = ' . $this->db->escape($tax_category_id) . '
order by cascade_sequence, tax_group, jurisdiction_name, tax_jurisdictions.tax_group_sequence + tax_categories.tax_group_sequence';
$query = $this->db->query($sql);
return $query ? $query->getResultArray() : [];
}
/**
* Gets information about a particular tax_code
*/
public function get_rate_info(int $tax_code_id, int $tax_category_id): object
{
$builder = $this->db->table('tax_rates');
$builder->join('tax_categories', 'rate_tax_category_id = tax_category_id');
$builder->where('rate_tax_code_id', $tax_code_id);
$builder->where('rate_tax_category_id', $tax_category_id);
$query = $builder->get();
if ($query->getNumRows() == 1) { // TODO: this should probably be ===
return $query->getRow();
} else {
// Get empty base parent object
$tax_rate_obj = new stdClass();
// Get all the fields from tax_codes table
foreach ($this->db->getFieldNames('tax_rates') as $field) {
$tax_rate_obj->$field = '';
}
// Get all the fields from tax_rates table
foreach ($this->db->getFieldNames('tax_categories') as $field) {
$tax_rate_obj->$field = '';
}
return $tax_rate_obj;
}
}
/**
* Inserts or updates a tax_rates entry
*/
public function save_value(array &$tax_rate_data, int $tax_rate_id = NEW_ENTRY): bool
{
$builder = $this->db->table('tax_rates');
if ($tax_rate_id == NEW_ENTRY || !$this->exists($tax_rate_id)) {
if ($builder->insert($tax_rate_data)) {
$tax_rate_data['tax_rate_id'] = $this->db->insertID();
return true;
}
} else {
$builder->where('tax_rate_id', $tax_rate_id);
if ($builder->update($tax_rate_data)) {
return true;
}
}
return false;
}
/**
* Deletes a single tax rate entry
*/
public function delete($tax_rate_id = null, bool $purge = false): bool
{
$builder = $this->db->table('tax_rates');
return $builder->delete(['tax_rate_id' => $tax_rate_id]);
}
/**
* Deletes a list of tax rates
*/
public function delete_list(array $tax_rate_ids): bool
{
$builder = $this->db->table('tax_rates');
$builder->whereIn('tax_rate_id', $tax_rate_ids);
return $builder->delete();
}
/**
* Gets tax_codes
*/
public function get_found_rows(string $search): int
{
return $this->search($search, 0, 0, 'tax_code_name', 'asc', true);
}
/**
* Performs a search on tax_rates
*/
public function search(string $search, ?int $rows = 0, ?int $limit_from = 0, ?string $sort = 'tax_code_name', ?string $order = 'asc', ?bool $count_only = false)
{
// Set default values
if ($rows == null) $rows = 0;
if ($limit_from == null) $limit_from = 0;
if ($sort == null) $sort = 'tax_code_name';
if ($order == null) $order = 'asc';
if ($count_only == null) $count_only = false;
$builder = $this->db->table('tax_rates');
// get_found_rows case
if ($count_only) {
$builder->select('COUNT(tax_rate_id) as count');
} else {
$builder->select('tax_rate_id');
$builder->select('tax_code');
$builder->select('rate_tax_code_id');
$builder->select('tax_code_name');
$builder->select('rate_jurisdiction_id');
$builder->select('jurisdiction_name');
$builder->select('rate_tax_category_id');
$builder->select('tax_category');
$builder->select('tax_rate');
$builder->select('tax_rounding_code');
}
$builder->join('tax_codes', 'rate_tax_code_id = tax_code_id', 'LEFT');
$builder->join('tax_categories', 'rate_tax_category_id = tax_category_id', 'LEFT');
$builder->join('tax_jurisdictions', 'rate_jurisdiction_id = jurisdiction_id', 'LEFT');
if (!empty($search)) {
$builder->like('rate_tax_code', $search);
$builder->orLike('tax_code_name', $search);
}
// get_found_rows case
if ($count_only) {
return $builder->get()->getRow()->count;
}
$builder->orderBy($sort, $order);
if ($rows > 0) {
$builder->limit($rows, $limit_from);
}
return $builder->get();
}
/**
* @param string $tax_code_type
* @return string
*/
public function get_tax_code_type_name(string $tax_code_type): string // TODO: if this is being called from the view and passed through GET params then it will come through as a string... better if we can get it as an int though.
{
if ($tax_code_type == '0') { // TODO: ===. Also, replace this with ternary notation. The whole function becomes a nice one-liner.
return lang('Taxes.tax_included');
} else {
return lang('Taxes.tax_excluded');
}
}
/**
* @param int $tax_category_id
* @return string
*/
public function get_tax_category(int $tax_category_id): string
{
$builder = $this->db->table('tax_categories');
$builder->select('tax_category');
$builder->where('tax_category_id', $tax_category_id);
return $builder->get()->getRow()->tax_category;
}
/**
* @return ResultInterface
*/
public function get_all_tax_categories(): ResultInterface
{
$builder = $this->db->table('tax_categories');
$builder->orderBy('tax_category_id');
return $builder->get();
}
/**
* @param string $tax_category
* @return int
*/
public function get_tax_category_id(string $tax_category): int // TODO: $tax_category is not used in this function and get_tax_category_id() is not called in the code. It may be that this needs to be deprecated and removed.
{
$builder = $this->db->table('tax_categories');
$builder->select('tax_category_id');
return $builder->get()->getRow()->tax_category_id;
}
}