Compare commits

..

11 Commits

Author SHA1 Message Date
jekkos
629de65e73 Merge branch 'master' into fix/4554-encryption-docker-issue 2026-05-22 16:08:37 +02:00
jekkos
5450404cb2 fix: cast string returns to int in MY_Migration (#4560)
basename() returns string and database column values are strings,
but get_latest_migration() and get_current_version() declare int
return types. PHP 8.0+ enforces strict return types and no longer
silently coerces strings to int, causing a TypeError on fresh
installs.

Fixes #4559

Co-authored-by: Ollama <ollama@steganos.dev>
2026-05-22 16:07:21 +02:00
Ollama
bf5af2f2dc fix: address CodeRabbit review comments for encryption key persistence
- Always mirror encryption key to both .env and WRITEPATH (Docker safety)
- Guard array key access with isset() before reading in Encryption.php
- Fix encrypt_value() to not treat string '0' as empty
- Improve error logging for failed encryption attempts
2026-05-22 14:57:37 +02:00
Ollama
71c164ad18 Address CodeRabbit feedback: validate key length, clarify encryption failure handling
- loadKeyFromWritable() now validates key length >= 64 before accepting
- encrypt_value() renamed  param, defaults to failing encryption required
- Clearer error message when credentials not saved

GitHub-Issue: #4554
2026-05-22 01:38:30 +02:00
Ollama
f7280ea83e Refactor all encryption/decryption to use helper functions
Replaces direct encrypter calls with decrypt_value() and encrypt_value()
helpers throughout the codebase for consistent error handling:

- Config controller: SMTP, SMS, Mailchimp credential encryption
- Email_lib: SMTP password decryption
- Sms_lib: SMS password decryption
- Mailchimp_lib: API key decryption
- Customers controller: Mailchimp list ID decryption

Removes nullable EncrypterInterface property from Config controller as
encryption is now handled via helper functions.

GitHub-Issue: #4554
2026-05-22 01:28:35 +02:00
Ollama
26e8d9d80c Add decrypt_value() and encrypt_value() helper functions
Extracts the recurring decryption/encryption pattern into reusable helper
functions with consistent error handling:

- decrypt_value(): Safely decrypts encrypted values with try/catch
- encrypt_value(): Safely encrypts values with error handling

Both functions handle:
- Empty/null values gracefully
- Missing encryption key (logs warning)
- Encryption/decryption failures (logs error, returns default)

This pattern appears in 8+ locations across the codebase.

GitHub-Issue: #4554
2026-05-22 01:28:30 +02:00
objecttothis
b7384296c1 Bugfix: Sale search in register not handling trailing space properly (#4557)
* Fix is_valid_receipt method bug

Strings submitted with a trailing space and no number caused an unhandled exception because Sale::exists() expects an int but a string was passed to it.

- Add guards
- Minor PSR refactor

Signed-off-by: objec <objecttothis@gmail.com>

* Address review comments

Signed-off-by: objec <objecttothis@gmail.com>

---------

Signed-off-by: objec <objecttothis@gmail.com>
2026-05-22 01:43:24 +04:00
Ollama
403180db7b Add encryption_failed error message to language file
Added localization string for encryption failure error messages.

GitHub-Issue: #4554
2026-05-21 21:43:28 +02:00
Ollama
6d970953b6 Handle encryption unavailability gracefully in controllers
Changed EncrypterInterface property to nullable and added proper error
handling for cases where encryption key is not available.

Changes:
- Config controller: nullable encrypter property, try/catch around encryption
- Email_lib: check encryption before using encrypter
- Return meaningful error messages when encryption fails
- Log warnings when passwords saved without encryption

Users will now see clear error messages instead of unhandled exceptions
when encryption key cannot be initialized.

GitHub-Issue: #4554
2026-05-21 21:43:24 +02:00
Ollama
b2c2d350a7 Add fallback key loading from WRITEPATH in Encryption config
When encryption key is not available from .env or environment variables,
the config now attempts to load from WRITEPATH/config/encryption.key.

This supports Docker environments where:
- .env file is read-only or ephemeral
- Key was persisted to the writable volume via check_encryption()

GitHub-Issue: #4554
2026-05-21 21:43:18 +02:00
Ollama
9dc58a2c1f Fix encryption key persistence for Docker environments
The check_encryption() function now properly handles Docker/container
environments where ROOTPATH/.env may be read-only or ephemeral.

Changes:
- Returns false when key persistence fails instead of always returning true
- Removes error suppression (@) to properly detect write failures
- Adds fallback to WRITEPATH/config/encryption.key for container volumes
- Splits logic into separate functions for clarity and testability

Fixes encryption key being lost on container restarts, which caused
stored passwords to become undecryptable.

GitHub-Issue: #4554
2026-05-21 21:43:14 +02:00
18 changed files with 464 additions and 1147 deletions

View File

@@ -106,4 +106,54 @@ 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

@@ -1,4 +1,4 @@
<?php
<?php
use CodeIgniter\Router\RouteCollection;
@@ -12,40 +12,6 @@ $routes->get('login', 'Login::index');
$routes->post('login', 'Login::index');
$routes->post('migrate', 'Login::migrate');
$routes->get('sales', 'Sales::getIndex');
$routes->get('sales/customerDisplay', 'Sales::getCustomerDisplay');
$routes->get('sales/itemSearch', 'Sales::getItemSearch');
$routes->post('sales/selectCustomer', 'Sales::postSelectCustomer');
$routes->post('sales/changeMode', 'Sales::postChangeMode');
$routes->post('sales/setComment', 'Sales::postSetComment');
$routes->post('sales/setInvoiceNumber', 'Sales::postSetInvoiceNumber');
$routes->post('sales/setPaymentType', 'Sales::postSetPaymentType');
$routes->post('sales/setPrintAfterSale', 'Sales::postSetPrintAfterSale');
$routes->post('sales/setPriceWorkOrders', 'Sales::postSetPriceWorkOrders');
$routes->post('sales/setEmailReceipt', 'Sales::postSetEmailReceipt');
$routes->post('sales/addPayment', 'Sales::postAddPayment');
$routes->post('sales/add', 'Sales::postAdd');
$routes->post('sales/editItem/(:segment)', 'Sales::postEditItem/$1');
$routes->post('sales/deleteItem/(:segment)', 'Sales::getDeleteItem/$1');
$routes->post('sales/deletePayment/(:segment)', 'Sales::getDeletePayment/$1');
$routes->post('sales/removeCustomer', 'Sales::getRemoveCustomer');
$routes->post('sales/complete', 'Sales::postComplete');
$routes->post('sales/cancel', 'Sales::postCancel');
$routes->post('sales/suspend', 'Sales::postSuspend');
$routes->post('sales/unsuspend', 'Sales::postUnsuspend');
$routes->post('sales/checkInvoiceNumber', 'Sales::postCheckInvoiceNumber');
$routes->post('sales/changeItemNumber', 'Sales::postChangeItemNumber');
$routes->post('sales/changeItemName', 'Sales::postChangeItemName');
$routes->post('sales/changeItemDescription', 'Sales::postChangeItemDescription');
$routes->get('sales/suspended', 'Sales::getSuspended');
$routes->get('sales/discardSuspendedSale', 'Sales::getDiscardSuspendedSale');
$routes->get('sales/sales_keyboard_help', 'Sales::getSalesKeyboardHelp');
$routes->get('sales/receipt/(:num)', 'Sales::getReceipt/$1');
$routes->get('sales/invoice/(:num)', 'Sales::getInvoice/$1');
$routes->get('sales/edit/(:num)', 'Sales::getEdit/$1');
$routes->post('sales/delete/(:num)', 'Sales::postDelete/$1');
$routes->post('sales/save/(:num)', 'Sales::postSave/$1');
$routes->add('no_access/index/(:segment)', 'No_access::index/$1');
$routes->add('no_access/index/(:segment)/(:segment)', 'No_access::index/$1/$2');

View File

@@ -17,11 +17,9 @@ 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;
@@ -30,7 +28,6 @@ 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;
@@ -62,13 +59,6 @@ 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');
}
}
/**
@@ -82,7 +72,7 @@ class Config extends Secure_Controller
$npmDev = false;
$license = [];
$license[$i]['title'] = 'Open Source Point Of Sale ' . config('App')->application_version;
$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);
@@ -241,28 +231,6 @@ class Config extends Secure_Controller
$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());
$data['scale_export_formats'] = [
'txt' => 'TXT',
'csv' => 'CSV',
];
$data['scale_export_charsets'] = [
'windows-1256' => 'Windows-1256',
'utf-8' => 'UTF-8',
'windows-1252' => 'Windows-1252',
];
$data['scale_export_delimiters'] = [
';' => ';',
',' => ',',
"\t" => 'Tab',
];
$data['scale_export_fields_options'] = [
'legacy_code' => lang('Items.item_number'),
'item_number' => lang('Items.item_number'),
'repeat_item_number' => lang('Items.item_number'),
'name' => lang('Items.name'),
'unit_price' => lang('Items.unit_price'),
'legacy_tail' => lang('Items.item_number'),
];
// Load all the license statements, they are already XSS cleaned in the private function
$data['licenses'] = $this->_licenses();
@@ -278,25 +246,11 @@ class Config extends Secure_Controller
// Integrations Related fields
$data['mailchimp'] = [];
if (check_encryption()) { // TODO: Hungarian notation
if (!isset($this->encrypter)) {
helper('security');
$this->encrypter = Services::encrypter();
}
$data['mailchimp']['api_key'] = decrypt_value($this->config['mailchimp_api_key'] ?? null);
$data['mailchimp']['list_id'] = decrypt_value($this->config['mailchimp_list_id'] ?? null);
$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()
if (check_encryption()) {
remove_backup();
} else {
$data['mailchimp']['api_key'] = '';
$data['mailchimp']['list_id'] = '';
}
$data['mailchimp']['lists'] = $this->_mailchimp();
@@ -416,7 +370,6 @@ class Config extends Secure_Controller
'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,
'customer_display_enabled' => $this->request->getPost('customer_display_enabled') != 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
@@ -497,36 +450,13 @@ class Config extends Secure_Controller
*/
public function postSaveLocale(): ResponseInterface
{
$language = trim((string) $this->request->getPost('language'));
$languageCode = 'en';
$languageName = 'english';
if ($language !== '' && str_contains($language, ':')) {
$exploded = array_map('trim', explode(':', $language, 2));
if (count($exploded) === 2) {
$languageCode = htmlspecialchars($exploded[0]);
$languageName = htmlspecialchars($exploded[1]);
}
}
$exploded = explode(":", $this->request->getPost('language'));
$currency_symbol = $this->request->getPost('currency_symbol');
$secondaryCurrencyCode = strtoupper(trim((string) $this->request->getPost('secondary_currency_code')));
if (!preg_match('/^[A-Z]{3}$/', $secondaryCurrencyCode)) {
$secondaryCurrencyCode = '';
}
$batch_save_data = [
'currency_symbol' => htmlspecialchars($currency_symbol ?? ''),
'currency_code' => $this->request->getPost('currency_code'),
'secondary_currency_enabled' => $this->request->getPost('secondary_currency_enabled') != null,
'secondary_currency_symbol' => htmlspecialchars($this->request->getPost('secondary_currency_symbol') ?? ''),
'secondary_currency_code' => $secondaryCurrencyCode,
'secondary_currency_rate' => $this->request->getPost('secondary_currency_rate', FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION),
'secondary_currency_decimals' => $this->request->getPost('secondary_currency_decimals', FILTER_SANITIZE_NUMBER_INT),
'language_code' => $languageCode,
'language' => $languageName,
'language_code' => $exploded[0],
'language' => $exploded[1],
'timezone' => $this->request->getPost('timezone'),
'dateformat' => $this->request->getPost('dateformat'),
'timeformat' => $this->request->getPost('timeformat'),
@@ -558,15 +488,23 @@ class Config extends Secure_Controller
public function postSaveEmail(): ResponseInterface
{
$password = '';
$passwordInput = $this->request->getPost('smtp_pass');
if (check_encryption() && !empty($this->request->getPost('smtp_pass'))) {
$password = $this->encrypter->encrypt($this->request->getPost('smtp_pass'));
if (!empty($passwordInput)) {
$password = encrypt_value($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'),
]);
}
}
$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);
@@ -574,7 +512,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'),
]);
}
@@ -586,7 +524,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);
@@ -604,16 +542,25 @@ class Config extends Secure_Controller
public function postSaveMessage(): ResponseInterface
{
$password = '';
$passwordInput = $this->request->getPost('msg_pwd');
if (check_encryption() && !empty($this->request->getPost('msg_pwd'))) {
$password = $this->encrypter->encrypt($this->request->getPost('msg_pwd'));
if (!empty($passwordInput)) {
$password = encrypt_value($passwordInput);
if (empty($password)) {
log_message('error', 'SMS password encryption failed');
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
}
$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);
@@ -672,15 +619,29 @@ class Config extends Secure_Controller
$api_key = '';
$list_id = '';
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);
}
$api_key_input = $this->request->getPost('mailchimp_api_key');
if (!empty($api_key_input)) {
$api_key = encrypt_value($api_key_input);
if (empty($api_key)) {
log_message('error', 'Mailchimp API key encryption failed');
$list_id_unencrypted = $this->request->getPost('mailchimp_list_id');
if (!empty($list_id_unencrypted)) {
$list_id = $this->encrypter->encrypt($list_id_unencrypted);
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
}
$list_id_input = $this->request->getPost('mailchimp_list_id');
if (!empty($list_id_input)) {
$list_id = encrypt_value($list_id_input);
if (empty($list_id)) {
log_message('error', 'Mailchimp list ID encryption failed');
return $this->response->setJSON([
'success' => false,
'message' => lang('Config.encryption_failed'),
]);
}
}
@@ -970,7 +931,9 @@ class Config extends Secure_Controller
public function postSaveReceipt(): ResponseInterface
{
$batch_save_data = [
'receipt_template' => $this->request->getPost('receipt_template'),
'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'),
@@ -980,7 +943,6 @@ class Config extends Secure_Controller
'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_secondary_currency' => $this->request->getPost('receipt_show_secondary_currency') != 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,
@@ -1009,7 +971,7 @@ class Config extends Secure_Controller
$batchSaveData = [];
foreach ($currentShortcuts as $name => $shortcut) {
$postedValue = trim((string) $this->request->getPost('key_' . $name));
$postedValue = trim((string)$this->request->getPost('key_' . $name));
if (!in_array($postedValue, $allowedShortcuts, true)) {
$postedValue = $shortcut['value'];
@@ -1113,6 +1075,3 @@ class Config extends Secure_Controller
return in_array($column, $allowed, true) ? $column : $fallback;
}
}

View File

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

View File

@@ -66,168 +66,12 @@ class Sales extends Secure_Controller
$this->employee = model(Employee::class);
}
/**
* Adds the shared secondary currency context to a view data array.
*
* @param array $data
* @return void
*/
private function _append_secondary_currency(array &$data): void
{
$secondaryCurrency = secondary_currency_context($this->config);
$data['secondaryCurrency'] = $secondaryCurrency;
if (!$secondaryCurrency['show']) {
return;
}
$displayFields = [
'total' => 'secondaryTotalDisplay',
'amount_due' => 'secondaryAmountDueDisplay',
'cash_amount_due' => 'secondaryCashAmountDueDisplay',
'non_cash_total' => 'secondaryNonCashTotalDisplay',
'non_cash_amount_due' => 'secondaryNonCashAmountDueDisplay'
];
foreach ($displayFields as $sourceField => $targetField) {
if (array_key_exists($sourceField, $data)) {
$data[$targetField] = to_secondary_currency((float) $data[$sourceField], $secondaryCurrency);
}
}
}
public function getIndex(): ResponseInterface|string
{
$this->session->set('allow_temp_items', 1);
return $this->_reload(); // TODO: Hungarian Notation
}
/**
* Load the customer display popup.
*
* @return ResponseInterface|string
* @noinspection PhpUnused
*/
public function getCustomerDisplay(): ResponseInterface|string
{
if (($this->config['customer_display_enabled'] ?? false) != 1) {
return $this->response->setStatusCode(404)->setBody('');
}
if ($this->session->get('sale_id') == '') {
$this->session->set('sale_id', NEW_ENTRY);
}
$secondaryCurrency = secondary_currency_context($this->config);
$secondaryCurrencyEnabled = (($this->config['secondary_currency_enabled'] ?? false) == 1);
$cashRounding = $this->sale_lib->reset_cash_rounding();
$showCustomerDisplay = $secondaryCurrencyEnabled && !empty($secondaryCurrency['rate']) && (float) $secondaryCurrency['rate'] > 0;
$companyLines = preg_split("/\r\n|\r|\n/", (string) ($this->config['company'] ?? '')) ?: [];
$companyName = array_shift($companyLines) ?? '';
$companyDetails = trim(implode("\n", $companyLines));
$secondaryCurrencySymbol = trim((string) ($this->config['secondary_currency_symbol'] ?? ''));
$secondaryCurrencyCode = trim((string) ($this->config['secondary_currency_code'] ?? ''));
$originalCurrencySymbol = trim((string) ($this->config['currency_symbol'] ?? ''));
$customerDisplayCurrencyLabel = $secondaryCurrencyCode !== '' ? $secondaryCurrencyCode : ($secondaryCurrencySymbol !== '' ? $secondaryCurrencySymbol : 'LBP');
$originalCurrencyLabel = $originalCurrencySymbol !== '' ? $originalCurrencySymbol : '$';
$cartHasCustomerDisplay = $showCustomerDisplay;
$cartColspan = $cartHasCustomerDisplay ? 6 : 5;
$cartItemWidth = $cartHasCustomerDisplay ? 32 : 44;
$cartPriceWidth = $cartHasCustomerDisplay ? 18 : 0;
$cartOriginalWidth = $cartHasCustomerDisplay ? 18 : 26;
$cartQuantityWidth = $cartHasCustomerDisplay ? 12 : 10;
$cartDiscountWidth = $cartHasCustomerDisplay ? 10 : 9;
$cartTotalWidth = $cartHasCustomerDisplay ? 10 : 11;
$data = [
'cash_rounding' => $cashRounding,
'cart' => $this->sale_lib->get_cart()
];
$customer_info = $this->_load_customer_data($this->sale_lib->get_customer(), $data, true);
$data += [
'customer_name' => $data['customer'] ?? lang('Sales.walk_in_customer'),
'customer_reward_points' => (int) ($data['customer_rewards']['points'] ?? 0),
'customer_reward_package' => $data['customer_rewards']['package_name'] ?? '',
'giftcard_remainder' => $this->sale_lib->get_giftcard_remainder(),
'rewards_remainder' => $this->sale_lib->get_rewards_remainder(),
'customerName' => $data['customer'] ?? lang('Sales.walk_in_customer'),
'customerRewardPoints' => (int) ($data['customer_rewards']['points'] ?? 0),
'giftcardRemainder' => $this->sale_lib->get_giftcard_remainder()
];
$tax_details = $this->tax_lib->get_taxes($data['cart']);
$data += [
'tax_exclusive_subtotal' => $this->sale_lib->get_subtotal(true, true),
'taxes' => $tax_details[0],
'discount' => $this->sale_lib->get_discount(),
'payments' => $this->sale_lib->get_payments()
];
$totals = $this->sale_lib->get_totals($tax_details[0]);
$data += [
'item_count' => $totals['item_count'],
'total_units' => $totals['total_units'],
'subtotal' => $totals['subtotal'],
'total' => $totals['total'],
'payments_total' => $totals['payment_total'],
'payments_cover_total' => $totals['payments_cover_total'],
'prediscount_subtotal' => $totals['prediscount_subtotal'],
'cash_total' => $totals['cash_total'],
'non_cash_total' => $totals['total'],
'cash_amount_due' => $totals['cash_amount_due'],
'non_cash_amount_due' => $totals['amount_due'],
'cash_mode' => $this->session->get('cash_mode'),
'selected_payment_type' => $this->sale_lib->get_payment_type(),
'comment' => $this->sale_lib->get_comment(),
'email_receipt' => $this->sale_lib->is_email_receipt(),
'config' => $this->config,
'mode' => $this->sale_lib->get_mode(),
'rate' => (float) ($secondaryCurrency['rate'] ?? $this->config['secondary_currency_rate'] ?? 0),
'secondaryCurrency' => $secondaryCurrency,
'secondaryCurrencyEnabled' => $secondaryCurrencyEnabled,
'showCustomerDisplay' => $showCustomerDisplay,
'companyName' => $companyName,
'companyDetails' => $companyDetails,
'secondaryCurrencySymbol' => $secondaryCurrencySymbol,
'secondaryCurrencyCode' => $secondaryCurrencyCode,
'originalCurrencySymbol' => $originalCurrencySymbol,
'customerDisplayCurrencyLabel' => $customerDisplayCurrencyLabel,
'originalCurrencyLabel' => $originalCurrencyLabel,
'cartHasCustomerDisplay' => $cartHasCustomerDisplay,
'cartColspan' => $cartColspan,
'cartItemWidth' => $cartItemWidth,
'cartPriceWidth' => $cartPriceWidth,
'cartOriginalWidth' => $cartOriginalWidth,
'cartQuantityWidth' => $cartQuantityWidth,
'cartDiscountWidth' => $cartDiscountWidth,
'cartTotalWidth' => $cartTotalWidth,
'items_module_allowed' => $this->employee->has_grant('items', $this->employee->get_logged_in_employee_info()->person_id),
'change_price' => $this->employee->has_grant('sales_change_price', $this->employee->get_logged_in_employee_info()->person_id)
];
$invoice_number = $this->sale_lib->get_invoice_number();
if ($invoice_number == null || $invoice_number == '') {
$invoice_number = $this->token_lib->render($this->config['sales_invoice_format'], [], false);
}
$data += [
'invoice_number' => $invoice_number,
'print_after_sale' => $this->sale_lib->is_print_after_sale(),
'price_work_orders' => $this->sale_lib->is_price_work_orders(),
'pos_mode' => $data['mode'] == 'sale' || $data['mode'] == 'return',
'quote_number' => $this->sale_lib->get_quote_number(),
'work_order_number' => $this->sale_lib->get_work_order_number(),
'amount_due' => $data['cash_mode'] && ($data['selected_payment_type'] === lang('Sales.cash') || $data['payments_total'] > 0) ? $totals['cash_amount_due'] : $totals['amount_due']
];
$data['amount_change'] = $data['amount_due'] * -1;
$data['payment_change_due'] = ((float) $data['amount_due'] < 0)
? abs((float) $data['amount_due'])
: max(((float) $data['payments_total']) - ((float) $data['amount_due']), 0);
$data['paymentChangeDue'] = $data['payment_change_due'];
return view('sales/customer_display', $data);
}
/**
* Load the sale edit modal. Used in app/Views/sales/register.php.
*
@@ -249,6 +93,8 @@ class Sales extends Secure_Controller
'only_check' => lang('Sales.check_filter'),
'only_creditcard' => lang('Sales.credit_filter'),
'only_debit' => lang('Sales.debit'),
'only_bank_transfer'=> lang('Sales.bank_transfer'),
'only_wallet' => lang('Sales.wallet'),
'only_invoices' => lang('Sales.invoice_filter'),
'selected_customer' => lang('Sales.selected_customer')
];
@@ -312,8 +158,10 @@ class Sales extends Secure_Controller
'selected_customer' => false,
'only_creditcard' => false,
'only_debit' => false,
'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->is_valid_receipt($search)
'is_valid_receipt' => $this->sale->isValidReceipt($search)
];
// Check if any filter is set in the multiselect dropdown
@@ -350,7 +198,7 @@ class Sales extends Secure_Controller
? $this->request->getGet('term')
: null;
if ($this->sale_lib->get_mode() == 'return' && $this->sale->is_valid_receipt($receipt)) {
if ($this->sale_lib->get_mode() == 'return' && $this->sale->isValidReceipt($receipt)) {
// If a valid receipt or invoice was found the search term will be replaced with a receipt number (POS #)
$suggestions[] = $receipt;
}
@@ -677,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->is_valid_receipt($item_id_or_number_or_item_kit_or_receipt)) {
if ($mode == 'return' && $this->sale->isValidReceipt($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
@@ -970,7 +818,6 @@ class Sales extends Secure_Controller
// Resort and filter cart lines for printing
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
$this->_append_secondary_currency($data);
if ($data['sale_id_num'] == NEW_ENTRY) {
$data['error_message'] = lang('Sales.transaction_failed');
@@ -1010,7 +857,6 @@ class Sales extends Secure_Controller
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
$data['barcode'] = null;
$this->_append_secondary_currency($data);
$this->sale_lib->clear_all();
return view('sales/work_order', $data);
@@ -1038,7 +884,6 @@ class Sales extends Secure_Controller
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
$data['barcode'] = null;
$this->_append_secondary_currency($data);
$this->sale_lib->clear_all();
return view('sales/quote', $data);
@@ -1057,13 +902,20 @@ class Sales extends Secure_Controller
$data['sale_id'] = 'POS ' . $data['sale_id_num'];
$data['cart'] = $this->sale_lib->sort_and_filter_cart($data['cart']);
$this->_append_secondary_currency($data);
if ($data['sale_id_num'] == NEW_ENTRY) {
$data['error_message'] = lang('Sales.transaction_failed');
return $this->_reload($data);
} else {
$data['barcode'] = $this->barcode_lib->generate_receipt_barcode($data['sale_id']);
// Validate receipt template to prevent path traversal
$receipt_template = $this->config['receipt_template'] ?? '';
if (!Sale_lib::isValidReceiptTemplate($receipt_template)) {
$receipt_template = 'receipt_default';
}
$data['receipt_template_view'] = $receipt_template;
$this->sale_lib->clear_all();
return view('sales/receipt', $data);
}
@@ -1318,7 +1170,13 @@ class Sales extends Secure_Controller
$invoice_type = 'invoice';
}
$data['invoice_view'] = $invoice_type;
$this->_append_secondary_currency($data);
// Validate receipt template to prevent path traversal
$receipt_template = $this->config['receipt_template'] ?? '';
if (!Sale_lib::isValidReceiptTemplate($receipt_template)) {
$receipt_template = 'receipt_default';
}
$data['receipt_template_view'] = $receipt_template;
return $data;
}
@@ -1385,7 +1243,6 @@ class Sales extends Secure_Controller
}
$data['amount_change'] = $data['amount_due'] * -1;
$this->_append_secondary_currency($data);
$data['comment'] = $this->sale_lib->get_comment();
$data['email_receipt'] = $this->sale_lib->is_email_receipt();
@@ -1415,6 +1272,7 @@ class Sales extends Secure_Controller
$data['quote_number'] = $this->sale_lib->get_quote_number();
$data['work_order_number'] = $this->sale_lib->get_work_order_number();
$data['keyboardShortcuts'] = $this->sale_lib->getKeyShortcuts();
// TODO: the if/else set below should be converted to a switch
if ($this->sale_lib->get_mode() == 'sale_invoice') { // TODO: Duplicated code.
@@ -1803,7 +1661,9 @@ class Sales extends Secure_Controller
*/
public function getSalesKeyboardHelp(): string
{
return view('sales/help');
return view('sales/help', [
'keyboardShortcuts' => $this->sale_lib->getKeyShortcuts()
]);
}
/**
@@ -1925,5 +1785,3 @@ class Sales extends Secure_Controller
return null;
}
}

View File

@@ -272,6 +272,9 @@ function get_payment_options(): array
$payments[lang('Sales.upi')] = lang('Sales.upi');
}
$payments[lang('Sales.bank_transfer')] = lang('Sales.bank_transfer');
$payments[lang('Sales.wallet')] = lang('Sales.wallet');
return $payments;
}
@@ -365,74 +368,6 @@ function to_currency_no_money(?string $number): string
return to_decimals($number, 'currency_decimals');
}
/**
* Build the secondary currency rendering context from app config values.
*
* @param array $config
* @return array{show:bool,rate:float,symbol:string,code:string,decimals:int}
*/
function secondary_currency_context(array $config): array
{
$rate = (float) ($config['secondary_currency_rate'] ?? 0);
$symbol = trim((string) ($config['secondary_currency_symbol'] ?? ''));
$code = trim((string) ($config['secondary_currency_code'] ?? ''));
$decimals = (int) ($config['secondary_currency_decimals'] ?? ($config['currency_decimals'] ?? DEFAULT_PRECISION));
return [
'show' => (($config['secondary_currency_enabled'] ?? false) == 1) && $rate > 0,
'rate' => $rate,
'symbol' => $symbol,
'code' => $code,
'decimals' => $decimals,
];
}
/**
* Render a value in the secondary currency.
*
* @param float|int|string|null $number
* @param array{show:bool,rate:float,symbol:string,code:string,decimals:int} $secondaryCurrency
* @return string
*/
function to_secondary_currency(float|int|string|null $number, array $secondaryCurrency): string
{
if (!isset($number) || !$secondaryCurrency['show']) {
return '';
}
$config = config(OSPOS::class)->settings;
$amount = (float) $number * (float) $secondaryCurrency['rate'];
$fmt = new NumberFormatter($config['number_locale'], NumberFormatter::CURRENCY);
$fmt->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $secondaryCurrency['decimals']);
$fmt->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $secondaryCurrency['decimals']);
if (empty($config['thousands_separator'])) {
$fmt->setTextAttribute(NumberFormatter::GROUPING_SEPARATOR_SYMBOL, '');
}
$fmt->setSymbol(NumberFormatter::CURRENCY_SYMBOL, $secondaryCurrency['symbol'] !== '' ? $secondaryCurrency['symbol'] : ($secondaryCurrency['code'] !== '' ? $secondaryCurrency['code'] : ''));
return $fmt->format($amount);
}
/**
* Render the secondary and primary currency amounts together.
*
* @param float|int|string|null $number
* @param array{show:bool,rate:float,symbol:string,code:string,decimals:int} $secondaryCurrency
* @return string
*/
function to_secondary_currency_dual(float|int|string|null $number, array $secondaryCurrency): string
{
$secondary = to_secondary_currency($number, $secondaryCurrency);
if ($secondary === '') {
return to_currency((string) $number);
}
return $secondary . '<br>' . to_currency((string) $number);
}
/**
* @param string|null $number
* @return string

View File

@@ -4,67 +4,227 @@ use CodeIgniter\Encryption\Encryption;
use Config\Services;
/**
* @return bool
* 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
*/
function check_encryption(): bool
{
$old_key = config('Encryption')->key;
if ((empty($old_key)) || (strlen($old_key) < 64)) {
$encryption = new Encryption();
$key = bin2hex($encryption->createKey());
config('Encryption')->key = $key;
// Key already exists and is valid (64+ hex chars = 32+ bytes)
if (!empty($old_key) && strlen($old_key) >= 64) {
return true;
}
$config_path = ROOTPATH . '.env';
$backup_path = WRITEPATH . '/backup/.env.bak';
$backup_folder = WRITEPATH . '/backup';
// Generate a new key
$encryption = new Encryption();
$key = bin2hex($encryption->createKey());
config('Encryption')->key = $key;
if (!file_exists($backup_folder)) {
@mkdir($backup_folder, 0750, true);
}
// Try to persist the key - attempt multiple locations
// Write both locations when possible. The writable copy is the durable one
// in containerized deployments where .env may be ephemeral.
$envPersisted = write_encryption_key_to_env($key, $old_key);
$writablePersisted = write_encryption_key_to_writable($key, $old_key);
$persisted = $envPersisted || $writablePersisted;
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");
}
@chmod($config_path, 0640);
}
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.');
}
if (file_exists($config_path)) {
@copy($config_path, $backup_path);
@chmod($backup_path, 0640);
@chmod($config_path, 0640);
return $persisted;
}
$config_file = file_get_contents($config_path);
/**
* Writes encryption key to ROOTPATH/.env file.
*
* @param string $key The new encryption key (hex-encoded)
* @param string|null $old_key The previous key to preserve for key rotation
*
* @return bool True if key was written successfully, false otherwise
*/
function write_encryption_key_to_env(string $key, ?string $old_key = null): bool
{
$config_path = ROOTPATH . '.env';
$backup_path = WRITEPATH . 'backup' . DIRECTORY_SEPARATOR . '.env.bak';
$backup_folder = WRITEPATH . 'backup';
if (strpos($config_file, 'encryption.key') !== false) {
$config_file = preg_replace("/(encryption\.key.*=.*)('.*')/", "$1'$key'", $config_file);
} else {
$config_file .= "\nencryption.key = '$key'\n";
}
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);
}
}
@file_put_contents($config_path, $config_file);
@chmod($config_path, 0640);
log_message('info', "Updated encryption key in $config_path");
// Ensure backup directory exists
if (!file_exists($backup_folder)) {
if (!@mkdir($backup_folder, 0750, true)) {
log_message('debug', 'Could not create backup directory');
}
}
// Create .env if it doesn't exist
if (!file_exists($config_path)) {
$example_path = ROOTPATH . '.env.example';
if (file_exists($example_path)) {
if (!@copy($example_path, $config_path)) {
log_message('debug', 'Could not copy .env.example to .env');
}
} else {
if (!@file_put_contents($config_path, "# OSPOS Configuration\n\n") !== false) {
log_message('debug', 'Could not create .env file');
}
}
@chmod($config_path, 0640);
}
// Check if .env is writable
if (!is_writable($config_path)) {
log_message('debug', '.env file is not writable');
return false;
}
// Backup existing .env
if (file_exists($config_path)) {
@copy($config_path, $backup_path);
@chmod($backup_path, 0640);
}
// Read current content
$config_file = file_get_contents($config_path);
if ($config_file === false) {
log_message('debug', 'Could not read .env file');
return false;
}
if (strpos($config_file, 'encryption.key') !== false) {
$config_file = preg_replace("/(encryption\.key.*=.*)(['\"])([^'\"]*)\\2/", "$1'$key'", $config_file);
} else {
$config_file .= "\nencryption.key = '$key'\n";
}
// Preserve old key for rotation if present
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);
}
}
// Write updated content
$result = file_put_contents($config_path, $config_file);
if ($result === false) {
log_message('debug', 'Could not write to .env file');
return false;
}
@chmod($config_path, 0640);
log_message('info', "Updated encryption key in $config_path");
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 $old_key The previous key to preserve for key rotation
*
* @return bool True if key was written successfully, false otherwise
*/
function write_encryption_key_to_writable(string $key, ?string $old_key = null): bool
{
$key_file = WRITEPATH . 'config' . DIRECTORY_SEPARATOR . 'encryption.key';
$key_dir = dirname($key_file);
// Ensure directory exists
if (!is_dir($key_dir)) {
if (!@mkdir($key_dir, 0750, true)) {
log_message('error', 'Could not create config directory: ' . $key_dir);
return false;
}
}
// Check if directory is writable
if (!is_writable($key_dir)) {
log_message('error', 'Config directory is not writable: ' . $key_dir);
return false;
}
// Build key data structure
$data = [
'key' => $key,
'previous_keys' => [],
'generated_at' => date('c'),
'generated_by' => 'check_encryption()',
];
if (!empty($old_key)) {
$data['previous_keys'][] = $old_key;
}
// Write key file
$content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$result = file_put_contents($key_file, $content);
if ($result === false) {
log_message('error', 'Could not write encryption key file');
return false;
}
// Set restrictive permissions
@chmod($key_file, 0640);
log_message('info', "Stored encryption key in $key_file");
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 load_encryption_key_from_writable(): ?string
{
$key_file = WRITEPATH . 'config' . DIRECTORY_SEPARATOR . 'encryption.key';
if (!file_exists($key_file)) {
return null;
}
if (!is_readable($key_file)) {
log_message('error', 'Encryption key file exists but is not readable: ' . $key_file);
return null;
}
$content = file_get_contents($key_file);
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 abort_encryption_conversion(): void
@@ -83,14 +243,77 @@ function abort_encryption_conversion(): void
}
/**
* Removes backup file (used after successful migration).
*
* @return void
*/
function remove_backup(): void
{
$backup_path = WRITEPATH . '/backup/.env.bak';
if (!file_exists($backup_path)) {
return;
if (file_exists($backup_path)) {
unlink($backup_path);
}
@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 $encrypted_value 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 decrypt_value(?string $encrypted_value, string $default = ''): string
{
if (empty($encrypted_value)) {
return $default;
}
if (!check_encryption()) {
log_message('warning', 'Cannot decrypt value: encryption key not available');
return $default;
}
try {
$encrypter = Services::encrypter();
return $encrypter->decrypt($encrypted_value);
} catch (\CodeIgniter\Encryption\Exceptions\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 If true, return empty string on failure instead of plaintext fallback
*
* @return string The encrypted value, or empty string if encryption fails when required
*/
function encrypt_value(?string $value, bool $require = true): string
{
if ($value === null || $value === '') {
return '';
}
if (!check_encryption()) {
log_message('error', 'Cannot encrypt value: encryption key not available');
return $require ? '' : $value;
}
try {
$encrypter = Services::encrypter();
return $encrypter->encrypt($value);
} catch (\CodeIgniter\Encryption\Exceptions\EncryptionException $e) {
log_message('error', 'Encryption failed: ' . $e->getMessage());
return $require ? '' : $value;
}
}

View File

@@ -122,6 +122,7 @@ 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" => "",
@@ -268,19 +269,10 @@ return [
"receipt_show_company_name" => "Show Company Name",
"receipt_show_description" => "Show Description",
"receipt_show_serialnumber" => "Show Serial Number",
"receipt_show_secondary_currency" => "Show Secondary Currency",
"receipt_show_tax_ind" => "Show Tax Indicator",
"receipt_show_taxes" => "Show Taxes",
"receipt_show_total_discount" => "Show Total Discount",
"receipt_template" => "Receipt Template",
"secondary_currency" => "Secondary Currency",
"secondary_currency_decimals" => "Secondary Currency Decimals",
"secondary_currency_code" => "Secondary Currency Code",
"secondary_currency_enable" => "Enable Secondary Currency",
"secondary_currency_enable_tooltip" => "Show secondary currency fields and print/display values across the app.",
"secondary_currency_rate" => "Secondary Currency Rate",
"secondary_currency_settings" => "Secondary Currency Settings",
"secondary_currency_symbol" => "Secondary Currency Symbol",
"receiving_calculate_average_price" => "Calc avg. Price (Receiving)",
"recv_invoice_format" => "Receivings Invoice Format",
"register_mode_default" => "Default Register Mode",
@@ -297,7 +289,6 @@ return [
"security_issue" => "Security Vulnerability Warning",
"server_notice" => "Please use the below info for issue reporting.",
"service_charge" => "",
"customer_display" => "Customer Display",
"show_due_enable" => "",
"show_office_group" => "Show office icon",
"statistics" => "Send Statistics",
@@ -312,6 +303,10 @@ return [
"suggestions_layout" => "Search Suggestions Layout",
"suggestions_second_column" => "Column 2",
"suggestions_third_column" => "Column 3",
"shortcuts" => "Shortcuts",
"shortcuts_configuration" => "Sales Keyboard Shortcut Configuration",
"shortcuts_duplicate_bindings" => "Shortcut bindings must be unique.",
"shortcuts_save_error" => "Unable to save shortcut settings.",
"system_conf" => "Setup & Conf",
"system_info" => "System Info",
"table" => "Table",
@@ -340,5 +335,3 @@ return [
"work_order_enable" => "Work Order Support",
"work_order_format" => "Work Order Format",
];

View File

@@ -7,9 +7,9 @@ return [
"account_number" => "Account #",
"add_payment" => "Add Payment",
"amount_due" => "Amount Due",
"amount_due_lbp" => "Amount Due LBP",
"amount_tendered" => "Amount Tendered",
"authorized_signature" => "Authorized Signature",
"bank_transfer" => "Bank Transfer",
"cancel_sale" => "Cancel",
"cash" => "Cash",
"cash_1" => "",
@@ -20,8 +20,6 @@ return [
"cash_deposit" => "Cash Deposit",
"cash_filter" => "Cash",
"change_due" => "Change Due",
"change" => "Change",
"currency_rate" => "Currency Rate",
"change_price" => "Change Selling Price",
"check" => "Check",
"check_balance" => "Check remainder",
@@ -43,7 +41,6 @@ return [
"customer_address" => "Address",
"customer_discount" => "Discount",
"customer_email" => "Email",
"customer_name" => "Customer Name",
"customer_location" => "Location",
"customer_mailchimp_status" => "MailChimp Status",
"customer_optional" => "(Required for Due Payments)",
@@ -77,6 +74,12 @@ return [
"employee" => "Employee",
"entry" => "Entry",
"error_editing_item" => "Error editing item",
"negative_price_invalid" => "Price cannot be negative.",
"negative_quantity_invalid" => "Quantity cannot be negative.",
"negative_discount_invalid" => "Discount cannot be negative.",
"discount_percent_exceeds_100" => "Percentage discount cannot exceed 100%.",
"discount_exceeds_item_total" => "Discount cannot exceed the item total.",
"negative_total_invalid" => "Sale total cannot be negative. Check item discounts and quantities.",
"find_or_scan_item" => "Find or Scan Item",
"find_or_scan_item_or_receipt" => "Find or Scan Item or Receipt",
"giftcard" => "Gift Card",
@@ -107,7 +110,6 @@ return [
"item_name" => "Item Name",
"item_number" => "Item #",
"item_out_of_stock" => "Item is out of stock.",
"items" => "Items",
"key_browser" => "Helpful Shortcuts",
"key_cancel" => "Cancels Current Quote/Invoice/Sale",
"key_customer_search" => "Customer Search",
@@ -149,9 +151,7 @@ return [
"payment_type" => "Type",
"payments" => "",
"payments_total" => "Payments Total",
"loyalty_reward_points" => "Loyalty Reward Points",
"price" => "Price",
"price_with_currency" => "Price (%s)",
"print_after_sale" => "Print after Sale",
"quantity" => "Quantity",
"quantity_less_than_reorder_level" => "Warning: Desired Quantity is below Reorder Level for that Item.",
@@ -167,13 +167,10 @@ return [
"receipt_number" => "Sale #",
"receipt_sent" => "Receipt sent to",
"receipt_unsent" => "Receipt failed to be sent to",
"rate" => "Rate",
"refund" => "Refund Type",
"register" => "Sales Register",
"remove_customer" => "Remove Customer",
"remove_discount" => "",
"customer_display" => "Customer Display",
"summary" => "Summary",
"return" => "Return",
"rewards" => "Reward Points",
"rewards_balance" => "Reward Points Balance",
@@ -185,7 +182,6 @@ return [
"sales_total" => "",
"select_customer" => "Select Customer",
"selected_customer" => "Selected Customer",
"walk_in_customer" => "Walk-in Customer",
"send_invoice" => "Send Invoice",
"send_quote" => "Send Quote",
"send_receipt" => "Send Receipt",
@@ -216,7 +212,6 @@ return [
"tax_percent" => "Tax %",
"taxed_ind" => "T",
"total" => "Total",
"total_lbp" => "Total LBP",
"total_tax_exclusive" => "Tax excluded",
"transaction_failed" => "Sales Transaction failed.",
"unable_to_add_item" => "Item add to Sale failed",
@@ -229,6 +224,7 @@ return [
"update" => "Update",
"upi" => "UPI",
"visa" => "",
"wallet" => "Wallet",
"wholesale" => "",
"work_order" => "Work Order",
"work_order_number" => "Work Order Number",
@@ -236,5 +232,3 @@ return [
"work_order_sent" => "Work Order sent to",
"work_order_unsent" => "Work Order failed to be sent to",
];

View File

@@ -3,11 +3,7 @@
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;
/**
@@ -26,19 +22,7 @@ class Email_lib
$this->email = new Email();
$this->config = config(OSPOS::class)->settings;
$encrypter = Services::encrypter();
$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 = '';
}
}
$smtp_pass = decrypt_value($this->config['smtp_pass'] ?? null);
$email_config = [
'mailType' => 'html',
@@ -51,7 +35,7 @@ class Email_lib
'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($email_config);
}

View File

@@ -25,7 +25,7 @@ class MY_Migration extends MigrationRunner
public function get_latest_migration(): int
{
$migrations = $this->findMigrations();
return basename(end($migrations)->version);
return (int) 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 ? $result->version : 0;
return $result ? (int) $result->version : 0;
}
} catch (\Exception $e) {
// Database not available yet (e.g. fresh install before schema).

View File

@@ -2,9 +2,7 @@
namespace app\Libraries;
use CodeIgniter\Encryption\EncrypterInterface;
use Config\OSPOS;
use Config\Services;
/**
* MailChimp API v3 REST client Connector
@@ -14,8 +12,6 @@ use Config\Services;
* 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
{
@@ -40,23 +36,19 @@ class MailchimpConnector
{
$config = config(OSPOS::class)->settings;
$encrypter = Services::encrypter();
$mailchimp_api_key = (isset($this->config['mailchimp_api_key']) && !empty($this->config['mailchimp_api_key']))
? $this->config['mailchimp_api_key']
: '';
$mailchimp_api_key = $config['mailchimp_api_key'] ?? '';
if (!empty($mailchimp_api_key)) {
$this->_api_key = empty($api_key)
? $encrypter->decrypt($mailchimp_api_key) // TODO: Hungarian notation
: $api_key; // TODO: Hungarian notation
? decrypt_value($mailchimp_api_key)
: $api_key;
}
if (!empty($this->_api_key)) { // TODO: Hungarian notation
if (!empty($this->_api_key)) {
// Replace <dc> with correct datacenter obtained from the last part of the api key
$strings = explode('-', $this->_api_key); // TODO: Hungarian notation
$strings = explode('-', $this->_api_key);
if (is_array($strings) && !empty($strings[1])) {
$this->_api_endpoint = str_replace('<dc>', $strings[1], $this->_api_endpoint); // TODO: Hungarian notation
$this->_api_endpoint = str_replace('<dc>', $strings[1], $this->_api_endpoint);
}
}
}

View File

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

View File

@@ -327,7 +327,7 @@ class Sale extends Model
{
$suggestions = [];
if (!$this->is_valid_receipt($search)) {
if (!$this->isValidReceipt($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 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.
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.
{
$config = config(OSPOS::class)->settings;
if (!empty($receipt_sale_id)) {
if (!empty($receiptSaleId)) {
// POS #
$pieces = explode(' ', $receipt_sale_id);
$pieces = explode(' ', trim($receiptSaleId));
if (count($pieces) == 2 && preg_match('/(POS)/i', $pieces[0])) {
return $this->exists($pieces[1]);
if (count($pieces) == 2 && strtoupper($pieces[0]) === 'POS' && ctype_digit($pieces[1])) {
return $this->exists((int)$pieces[1]);
} elseif ($config['invoice_enable']) {
$sale_info = $this->get_sale_by_invoice_number($receipt_sale_id);
$saleInfo = $this->get_sale_by_invoice_number($receiptSaleId);
if ($sale_info->getNumRows() > 0) {
$receipt_sale_id = 'POS ' . $sale_info->getRow()->sale_id;
if ($saleInfo->getNumRows() > 0) {
$receiptSaleId = 'POS ' . $saleInfo->getRow()->sale_id;
return true;
}

View File

@@ -29,7 +29,7 @@
) ?>
</div>
<div class="col-sm-7">
<a href="<?= 'https://bootswatch.com/3/' . ('bootstrap' == ($config['theme']) ? 'default' : esc($config['theme'])) ?>" target="_blank" rel="noopener">
<a href="<?= 'https://bootswatch.com/3/' . ('bootstrap' == ($config['theme']) ? 'default' : esc($config['theme'])) ?>" target="_blank" rel=noopener>
<span><?= lang('Config.theme_preview') . ' ' . ucfirst(esc($config['theme'])) . ' ' ?></span>
<span class="glyphicon glyphicon-new-window"></span>
</a>
@@ -130,17 +130,14 @@
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Config.receiving_cost_price_method'), 'receiving_cost_price_method', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-3">
<?= form_dropdown(
'receiving_cost_price_method',
[
'average' => lang('Config.receiving_cost_price_method_average'),
'new' => lang('Config.receiving_cost_price_method_new'),
],
(($config['receiving_cost_price_method'] ?? (($config['receiving_calculate_average_price'] ?? 1) ? 'average' : 'new'))),
['id' => 'receiving_cost_price_method', 'class' => 'form-control']
) ?>
<?= form_label(lang('Config.receiving_calculate_average_price'), 'receiving_calculate_average_price', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-1">
<?= form_checkbox([
'name' => 'receiving_calculate_average_price',
'id' => 'receiving_calculate_average_price',
'value' => 'receiving_calculate_average_price',
'checked' => $config['receiving_calculate_average_price'] == 1
]) ?>
</div>
</div>
@@ -281,7 +278,7 @@
'checked' => $config['gcaptcha_enable'] == 1
]) ?>
<label class="control-label">
<a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noopener noreferrer">
<a href="https://www.google.com/recaptcha/admin" target="_blank">
<span class="glyphicon glyphicon-info-sign" data-toggle="tooltip" data-placement="right" title="<?= lang('Config.gcaptcha_tooltip') ?>"></span>
</a>
</label>
@@ -408,18 +405,6 @@
</div>
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Config.customer_display'), 'customer_display_enabled', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-1">
<?= form_checkbox([
'name' => 'customer_display_enabled',
'id' => 'customer_display_enabled',
'value' => 'customer_display_enabled',
'checked' => ($config['customer_display_enabled'] ?? 1) == 1
]) ?>
</div>
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Config.show_office_group'), 'show_office_group', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-1">
@@ -456,7 +441,6 @@
</div>
</div>
<div class="form-group form-group-sm">
<?= form_label(lang('Config.category_dropdown'), 'category_dropdown', ['class' => 'control-label col-xs-2']) ?>
<div class="col-xs-1">
@@ -557,6 +541,3 @@
}));
});
</script>

View File

@@ -1,249 +0,0 @@
<?php
/**
* @var array $config
* @var string $companyName
* @var string $companyDetails
*/
helper('url');
?>
<!doctype html>
<html lang="<?= esc(service('request')->getLocale()) ?>">
<head>
<meta charset="utf-8">
<title><?= lang('Sales.customer_display') ?></title>
<link rel="shortcut icon" type="image/x-icon" href="<?= base_url('images/favicon.ico') ?>">
<link rel="stylesheet" href="<?= base_url('resources/bootswatch/' . (empty($config['theme']) ? 'flatly' : esc($config['theme'])) . '/bootstrap.min.css') ?>">
<link rel="stylesheet" href="<?= base_url('resources/opensourcepos-8e34d6a398.min.css') ?>">
<style>
html, body {
margin: 0;
padding: 0;
background: #f8f8f8;
color: #333;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
body {
width: 100%;
overflow: hidden;
}
.customer-display-header {
background: #1f3143;
color: #fff;
text-align: center;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
padding: 6px 12px;
border-bottom: 1px solid #102131;
}
.customer-display-shell {
width: 100%;
max-width: 1280px;
margin: 0 auto;
padding: 12px 18px 18px;
box-sizing: border-box;
}
.customer-display-company {
text-align: center;
margin-bottom: 18px;
}
.customer-display-company img {
display: block;
margin: 0 auto 6px;
max-height: 84px;
max-width: 240px;
}
.customer-display-company .company-name {
font-size: 20px;
font-weight: 600;
line-height: 1.2;
margin-top: 12px;
}
.customer-display-company .company-details {
font-size: 13px;
line-height: 1.35;
white-space: pre-line;
}
.customer-display-company .company-phone {
font-size: 13px;
line-height: 1.35;
margin-top: 4px;
}
.customer-display-main-row {
display: flex;
gap: 14px;
align-items: flex-start;
margin-top: 6px;
}
.customer-display-cart-column {
flex: 1 1 auto;
min-width: 0;
}
.customer-display-summary-column {
flex: 0 0 320px;
width: 320px;
}
.customer-display-summary-panel,
.customer-display-info-panel,
.customer-display-items-panel {
margin-bottom: 0;
}
.customer-display-summary-panel .panel-heading,
.customer-display-info-panel .panel-heading,
.customer-display-items-panel .panel-heading {
font-weight: 600;
}
.customer-display-summary-panel .table,
.customer-display-info-table {
margin-bottom: 0;
font-size: 13px;
}
.customer-display-summary-panel .table > tbody > tr > th,
.customer-display-info-table > tbody > tr > th {
background: #f8fbfd;
width: 56%;
font-weight: 700;
}
.customer-display-summary-panel .table > tbody > tr > td,
.customer-display-info-table > tbody > tr > td {
width: 44%;
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.customer-display-summary-panel .rate-row th,
.customer-display-summary-panel .rate-row td {
color: #c00000;
}
.customer-display-summary-panel .summary-section-row th {
background: #eaf2f8;
color: #1f3b5b;
font-weight: 700;
}
.customer-display-summary-panel .summary-subtable {
width: 100%;
}
.customer-display-summary-panel .summary-subtable > tbody > tr > th {
background: #fdfefe;
font-weight: 600;
}
.customer-display-summary-panel .summary-subtable > tbody > tr > td {
font-weight: 600;
}
.register-wrap {
width: 100%;
}
#register {
width: 100%;
margin: 0;
table-layout: fixed;
background: #fff;
}
#register th,
#register td {
text-align: center;
vertical-align: middle;
padding: 6px 5px;
word-wrap: break-word;
}
#register thead th {
font-size: 12px;
font-weight: 600;
color: #333;
}
#register tbody td {
font-size: 15px;
}
#register tbody td.item-name-cell {
font-size: 16px;
text-align: left;
}
#register tbody td.price-cell {
font-size: 15px;
}
#register tbody td.serial-cell {
font-size: 12px;
color: #2F4F4F;
}
.customer-display-summary-panel .table > tbody > tr > th,
.customer-display-info-table > tbody > tr > th {
border-top: 1px solid #e5e5e5;
}
.customer-display-summary-panel .table > tbody > tr > td,
.customer-display-info-table > tbody > tr > td {
border-top: 1px solid #e5e5e5;
}
.customer-display-summary-panel .panel-body,
.customer-display-info-panel .panel-body,
.customer-display-items-panel .panel-body {
padding: 12px 15px;
}
.customer-display-summary-column .panel-body {
padding-top: 8px;
}
.customer-display-summary-column .customer-name-value,
.customer-display-summary-column .giftcard-value,
.customer-display-summary-column .reward-value {
text-align: right;
}
.customer-display-footer {
margin-top: 14px;
text-align: center;
font-size: 12px;
color: #777;
}
</style>
</head>
<body>
<div class="customer-display-header">Open Source Point of Sale</div>
<div class="customer-display-shell">
<div class="customer-display-company">
<?php if (!empty($config['company_logo'])) { ?>
<img src="<?= base_url('uploads/' . esc($config['company_logo'], 'url')) ?>" alt="company_logo">
<?php } ?>
<div class="company-name"><?= esc($companyName) ?></div>
<div class="company-phone">Phone: <?= esc((string)($config['phone'] ?? '')) ?></div>
<?php if ($companyDetails !== '') { ?>
<div class="company-details"><?= nl2br(esc($companyDetails)) ?></div>
<?php } ?>
</div>
<div class="customer-display-main-row">

View File

@@ -1,224 +0,0 @@
<?php
/**
* @var array $cart
* @var array $config
* @var float $rate
* @var float $total
* @var float $subtotal
* @var float $prediscount_subtotal
* @var array $taxes
* @var array $payments
* @var float $amount_change
*/
$priceWithCurrencyLabel = lang('Sales.price_with_currency');
?>
<?= view('partial/customer_display_header') ?>
<div class="customer-display-cart-column">
<div class="register-wrap">
<div class="panel panel-default customer-display-items-panel">
<div class="panel-heading"><?= lang('Sales.items') ?></div>
<div class="panel-body table-responsive">
<table class="table table-striped table-condensed" id="register">
<thead>
<tr>
<th style="width: <?= (int) $cartItemWidth ?>%;"><?= lang('Sales.item_name') ?></th>
<?php if ($cartHasCustomerDisplay) { ?>
<th style="width: <?= (int) $cartPriceWidth ?>%;"><?= sprintf($priceWithCurrencyLabel, esc($customerDisplayCurrencyLabel)) ?></th>
<?php } ?>
<th style="width: <?= (int) $cartOriginalWidth ?>%;"><?= sprintf($priceWithCurrencyLabel, esc($originalCurrencyLabel)) ?></th>
<th style="width: <?= (int) $cartQuantityWidth ?>%;"><?= lang('Sales.quantity') ?></th>
<th style="width: <?= (int) $cartDiscountWidth ?>%;"><?= lang('Sales.discount') ?></th>
<th style="width: <?= (int) $cartTotalWidth ?>%;"><?= lang('Sales.total') ?></th>
</tr>
</thead>
<tbody id="cart_contents">
<?php if (count($cart) == 0) { ?>
<tr>
<td colspan="<?= (int) $cartColspan ?>">
<div class="alert alert-dismissible alert-info"><?= lang('Sales.no_items_in_cart') ?></div>
</td>
</tr>
<?php } else { ?>
<?php foreach (array_reverse($cart, true) as $line => $item) { ?>
<tr>
<td class="item-name-cell">
<?= esc($item['name']) ?><br>
<?= !empty($item['attribute_values']) ? esc($item['attribute_values']) : '' ?>
</td>
<?php if ($cartHasCustomerDisplay) { ?>
<td class="price-cell">
<?= to_secondary_currency((float)$item['price'], $secondaryCurrency) ?>
</td>
<?php } ?>
<td class="price-cell">
<?= to_currency($item['price']) ?>
</td>
<td class="price-cell">
<?= to_quantity_decimals($item['quantity']) ?>
</td>
<td class="price-cell">
<?= to_decimals($item['discount'], 0) ?>
</td>
<td class="price-cell">
<?= $item['item_type'] == ITEM_AMOUNT_ENTRY ? to_currency_no_money($item['discounted_total']) : to_currency($item['discounted_total']) ?>
</td>
</tr>
<tr>
<td colspan="<?= $cartHasCustomerDisplay ? 3 : 2 ?>"></td>
<td class="serial-cell">
<?= $item['is_serialized'] == 1 ? lang('Sales.serial') : '' ?>
</td>
<td colspan="2" class="serial-cell">
<?php if ($item['is_serialized'] == 1) {
echo esc($item['serialnumber']);
} ?>
</td>
</tr>
<?php } ?>
<?php } ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="customer-display-summary-column">
<div class="panel panel-primary customer-display-summary-panel">
<div class="panel-heading"><?= lang('Sales.summary') ?></div>
<div class="panel-body">
<table class="table table-condensed summary-subtable">
<tbody>
<tr>
<th><?= lang('Sales.total') ?></th>
<td><?= to_currency($total) ?></td>
</tr>
<?php if ($showCustomerDisplay): ?>
<tr>
<th><?= lang('Sales.total') ?> <?= esc($customerDisplayCurrencyLabel) ?></th>
<td><?= to_secondary_currency((float)$total, $secondaryCurrency) ?></td>
</tr>
<tr class="rate-row">
<th><?= lang('Sales.rate') ?></th>
<td><?= number_format((float) $rate, 2) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
<table class="table table-condensed summary-subtable" style="margin-top: 10px;">
<tbody>
<tr class="summary-section-row">
<th colspan="2"><?= lang('Sales.customer') ?></th>
</tr>
<tr>
<th><?= lang('Sales.customer_name') ?></th>
<td class="customer-name-value"><?= esc($customerName ?? lang('Sales.walk_in_customer')) ?></td>
</tr>
<tr>
<th><?= lang('Sales.giftcard_balance') ?></th>
<td class="giftcard-value"><?= to_currency((float) ($giftcardRemainder ?? 0)) ?></td>
</tr>
<tr>
<th><?= lang('Sales.loyalty_reward_points') ?></th>
<td class="reward-value"><?= esc((string)($customerRewardPoints ?? 0)) ?></td>
</tr>
</tbody>
</table>
<table class="table table-condensed summary-subtable" style="margin-top: 10px;">
<tbody>
<tr class="summary-section-row">
<th colspan="2"><?= lang('Sales.change') ?></th>
</tr>
<tr>
<th><?= lang('Sales.payments_total') ?></th>
<td><?= to_currency($payments_total) ?></td>
</tr>
<tr>
<th><?= lang('Sales.amount_due') ?></th>
<td><?= to_currency($amount_due) ?></td>
</tr>
<?php if ($showCustomerDisplay): ?>
<tr>
<th><?= lang('Sales.amount_due') ?> <?= esc($customerDisplayCurrencyLabel) ?></th>
<td><?= to_secondary_currency((float)$amount_due, $secondaryCurrency) ?></td>
</tr>
<?php endif; ?>
<tr>
<th><?= lang('Sales.change_due') ?></th>
<td><?= to_currency($paymentChangeDue ?? 0) ?></td>
</tr>
<?php if ($showCustomerDisplay): ?>
<tr>
<th><?= lang('Sales.change_due') ?> <?= esc($customerDisplayCurrencyLabel) ?></th>
<td><?= to_secondary_currency((float)($paymentChangeDue ?? 0), $secondaryCurrency) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="customer-display-footer"></div>
</div>
<script>
const customerDisplayId = new URLSearchParams(window.location.search).get('displayId') || '';
const customerDisplayStorageSuffix = customerDisplayId !== '' ? '_' + customerDisplayId : '';
const customerDisplayStorageKeys = {
open: 'customerDisplayOpen' + customerDisplayStorageSuffix,
dirtyAt: 'customerDisplayDirtyAt' + customerDisplayStorageSuffix
};
localStorage.setItem(customerDisplayStorageKeys.open, '1');
let lastDirtyAt = localStorage.getItem(customerDisplayStorageKeys.dirtyAt) || '';
let refreshTimer = null;
const scheduleRefresh = function(dirtyAt) {
if (refreshTimer !== null) {
clearTimeout(refreshTimer);
}
refreshTimer = setTimeout(function() {
if (localStorage.getItem(customerDisplayStorageKeys.open) !== '1') {
return;
}
if (localStorage.getItem(customerDisplayStorageKeys.dirtyAt) === dirtyAt) {
window.location.reload();
}
}, 700);
};
const checkForRefresh = function() {
const dirtyAt = localStorage.getItem(customerDisplayStorageKeys.dirtyAt) || '';
if (dirtyAt !== '' && dirtyAt !== lastDirtyAt) {
lastDirtyAt = dirtyAt;
scheduleRefresh(dirtyAt);
}
};
window.addEventListener('storage', function(event) {
if (event.key === customerDisplayStorageKeys.dirtyAt) {
checkForRefresh();
}
});
setInterval(checkForRefresh, 500);
window.addEventListener('beforeunload', function() {
localStorage.removeItem(customerDisplayStorageKeys.open);
});
</script>
</body>
</html>

View File

@@ -61,20 +61,6 @@ if (isset($success)) {
helper('url');
?>
<?php if ($secondaryCurrency['show']): ?>
<?php $secondaryCurrencyLabel = $secondaryCurrency['symbol'] ?: $secondaryCurrency['code']; ?>
<table align="center" style="font-size: 22px; font-weight: 600; background-color: rgb(221, 221, 221); width: 25%; margin: 0 auto 0.5em; border: dashed 1px;">
<tr>
<td style="text-align: center; padding-right: 5%;"><?= lang(ucfirst($controller_name) . '.total') ?>:</td>
<td style="text-align: center;"><?= to_currency($total) ?></td>
</tr>
<tr>
<td style="text-align: center; padding-right: 5%;"><?= lang(ucfirst($controller_name) . '.total') ?> <?= esc($secondaryCurrencyLabel) ?>:</td>
<td style="text-align: center;"><?= $secondaryTotalDisplay ?? to_secondary_currency((float) $total, $secondaryCurrency) ?></td>
</tr>
</table>
<?php endif; ?>
<div id="register_wrapper">
<!-- Top register controls -->
@@ -104,16 +90,6 @@ helper('url');
</li>
<?php } ?>
<?php if (($config['customer_display_enabled'] ?? true) == 1) { ?>
<li class="pull-right">
<?= anchor(
"$controller_name/customerDisplay",
'<span class="glyphicon glyphicon-blackboard">&nbsp;</span>' . lang(ucfirst($controller_name) . '.customer_display'),
['class' => 'btn btn-success btn-sm', 'id' => 'show_customer_display', 'title' => lang(ucfirst($controller_name) . '.customer_display'), 'onclick' => 'return openCustomerDisplay(this.href);']
) ?>
</li>
<?php } ?>
<li class="pull-right">
<button class="btn btn-default btn-sm modal-dlg" id="show_suspended_sales_button" data-href="<?= esc("$controller_name/suspended") ?>"
title="<?= lang(ucfirst($controller_name) . '.suspended_sales') ?>">
@@ -215,7 +191,7 @@ helper('url');
if ($items_module_allowed && $change_price) {
echo form_input(['name' => 'price', 'class' => 'form-control input-sm', 'value' => to_currency_no_money($item['price']), 'tabindex' => ++$tabindex, 'onClick' => 'this.select();']);
} else {
echo $secondaryCurrency['show'] ? to_secondary_currency_dual((float) $item['price'], $secondaryCurrency) : to_currency($item['price']);
echo to_currency($item['price']);
echo form_hidden('price', to_currency_no_money($item['price']));
}
?>
@@ -386,6 +362,9 @@ helper('url');
<button class="btn btn-info btn-sm modal-dlg" data-btn-submit="<?= lang('Common.submit') ?>" data-href="<?= "customers/view" ?>" title="<?= lang(ucfirst($controller_name) . ".new_customer") ?>">
<span class="glyphicon glyphicon-user">&nbsp;</span><?= lang(ucfirst($controller_name) . ".new_customer") ?>
</button>
<button class="btn btn-default btn-sm modal-dlg" id="show_keyboard_help" data-href="<?= esc("$controller_name/salesKeyboardHelp") ?>" title="<?= lang(ucfirst($controller_name) . '.key_title') ?>">
<span class="glyphicon glyphicon-share-alt">&nbsp;</span><?= lang(ucfirst($controller_name) . '.key_help') ?>
</button>
</div>
<?php } ?>
<?= form_close() ?>
@@ -401,7 +380,7 @@ helper('url');
</tr>
<?php foreach ($taxes as $tax_group_index => $tax) { ?>
<tr>
<th style="width: 55%;"><?= (float)$tax['tax_rate'] . '% ' . esc($tax['tax_group']) ?></th>
<th style="width: 55%;"><?= (float)$tax['tax_rate'] . '% ' . $tax['tax_group'] ?></th>
<th style="width: 45%; text-align: right;"><?= to_currency_tax($tax['sale_tax_amount']) ?></th>
</tr>
<?php } ?>
@@ -409,12 +388,6 @@ helper('url');
<th style="width: 55%; font-size: 150%"><?= lang(ucfirst($controller_name) . '.total') ?></th>
<th style="width: 45%; font-size: 150%; text-align: right;"><span id="sale_total"><?= to_currency($total) ?></span></th>
</tr>
<?php if ($secondaryCurrency['show']) { ?>
<tr>
<th style="width: 55%; font-size: 120%"><?= lang(ucfirst($controller_name) . '.total') ?> <?= esc($secondaryCurrencyLabel) ?></th>
<th style="width: 45%; font-size: 120%; text-align: right;"><span id="sale_total_secondary_currency"><?= $secondaryTotalDisplay ?? to_secondary_currency((float) $total, $secondaryCurrency) ?></span></th>
</tr>
<?php } ?>
</table>
<?php if (count($cart) > 0) { // Only show this part if there are Items already in the register ?>
@@ -423,21 +396,16 @@ helper('url');
<th style="width: 55%;"><?= lang(ucfirst($controller_name) . '.payments_total') ?></th>
<th style="width: 45%; text-align: right;"><?= to_currency($payments_total) ?></th>
</tr>
<tr>
<th style="width: 55%; font-size: 120%"><?= lang(ucfirst($controller_name) . '.amount_due') ?></th>
<th style="width: 45%; font-size: 120%; text-align: right;"><span id="sale_amount_due"><?= to_currency($amount_due) ?></span></th>
</tr>
<?php if ($secondaryCurrency['show']) { ?>
<tr>
<th style="width: 55%; font-size: 120%"><?= lang(ucfirst($controller_name) . '.amount_due') ?> <?= esc($secondaryCurrencyLabel) ?></th>
<th style="width: 45%; font-size: 120%; text-align: right;"><span id="sale_amount_due_secondary_currency"><?= $secondaryAmountDueDisplay ?? to_secondary_currency((float) $amount_due, $secondaryCurrency) ?></span></th>
<th style="width: 55%; font-size: 120%"><?= lang(ucfirst($controller_name) . '.amount_due') ?></th>
<th style="width: 45%; font-size: 120%; text-align: right;"><span id="sale_amount_due"><?= to_currency($amount_due) ?></span></th>
</tr>
<?php } ?>
</table>
</table>
<div id="payment_details">
<?php if ($payments_cover_total) { // Show Complete sale button instead of Add Payment if there is no amount due left ?>
<?= form_open("$controller_name/addPayment", ['id' => 'add_payment_form', 'class' => 'form-horizontal']) ?>
<input type="hidden" name="complete_after_payment" value="0">
<table class="sales_table_100">
<tr>
<td><?= lang(ucfirst($controller_name) . '.payment') ?></td>
@@ -614,76 +582,8 @@ helper('url');
cancel: keyboardShortcuts?.cancel?.code ?? null
};
window.customerDisplayWindow = window.customerDisplayWindow || null;
window.customerDisplayDisplayId = window.customerDisplayDisplayId || sessionStorage.getItem('customerDisplayId') || localStorage.getItem('customerDisplayId') || '';
window.customerDisplayStorageSuffix = function() {
return window.customerDisplayDisplayId ? '_' + window.customerDisplayDisplayId : '';
};
window.customerDisplayStorageKeys = function() {
const suffix = window.customerDisplayStorageSuffix();
return {
open: 'customerDisplayOpen' + suffix,
dirtyAt: 'customerDisplayDirtyAt' + suffix
};
};
window.openCustomerDisplay = function(url) {
if (window.customerDisplayDisplayId === '') {
window.customerDisplayDisplayId = String(Date.now()) + Math.random().toString(36).slice(2);
}
const keys = window.customerDisplayStorageKeys();
const displayUrl = new URL(url, window.location.href);
displayUrl.searchParams.set('displayId', window.customerDisplayDisplayId);
sessionStorage.setItem('customerDisplayId', window.customerDisplayDisplayId);
localStorage.setItem('customerDisplayId', window.customerDisplayDisplayId);
localStorage.setItem(keys.open, '1');
localStorage.setItem(keys.dirtyAt, String(Date.now()));
window.customerDisplayWindow = window.open(displayUrl.toString(), 'customer_display_' + window.customerDisplayDisplayId, 'width=1280,height=720,resizable=yes,scrollbars=yes');
if (window.customerDisplayWindow && !window.customerDisplayWindow.closed) {
window.customerDisplayWindow.focus();
}
return false;
};
window.refreshCustomerDisplay = function() {
const keys = window.customerDisplayStorageKeys();
if (localStorage.getItem(keys.open) !== '1') {
return;
}
localStorage.setItem(keys.dirtyAt, String(Date.now()));
if (window.customerDisplayWindow && !window.customerDisplayWindow.closed) {
window.customerDisplayWindow.location.reload();
window.customerDisplayWindow.focus();
}
};
window.notifyCustomerDisplay = function() {
window.refreshCustomerDisplay();
};
const secondaryAmounts = <?= json_encode([
'total' => $secondaryTotalDisplay ?? null,
'amountDue' => $secondaryAmountDueDisplay ?? null,
'cashAmountDue' => $secondaryCashAmountDueDisplay ?? null,
'nonCashTotal' => $secondaryNonCashTotalDisplay ?? null,
'nonCashAmountDue' => $secondaryNonCashAmountDueDisplay ?? null
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>;
$(document).ready(function() {
setTimeout(function() {
window.notifyCustomerDisplay();
}, 300);
const redirect = function() {
window.notifyCustomerDisplay();
window.location.href = "<?= site_url('sales'); ?>";
};
@@ -711,10 +611,7 @@ helper('url');
'item_id': item_id,
'item_number': item_number,
},
dataType: 'json',
success: function() {
window.notifyCustomerDisplay();
}
dataType: 'json'
});
});
@@ -728,10 +625,7 @@ helper('url');
'item_id': item_id,
'item_name': item_name,
},
dataType: 'json',
success: function() {
window.notifyCustomerDisplay();
}
dataType: 'json'
});
});
@@ -745,10 +639,7 @@ helper('url');
'item_id': item_id,
'item_description': item_description,
},
dataType: 'json',
success: function() {
window.notifyCustomerDisplay();
}
dataType: 'json'
});
});
@@ -797,7 +688,6 @@ helper('url');
delay: 10,
select: function(a, ui) {
$(this).val(ui.item.value);
window.notifyCustomerDisplay();
$('#select_customer_form').submit();
return false;
}
@@ -816,7 +706,6 @@ helper('url');
delay: 10,
select: function(a, ui) {
$(this).val(ui.item.value);
window.notifyCustomerDisplay();
$('#add_payment_form').submit();
return false;
}
@@ -856,33 +745,28 @@ helper('url');
});
$('#finish_sale_button').click(function() {
window.notifyCustomerDisplay();
$('#buttons_form').attr('action', "<?= "$controller_name/complete" ?>");
$('#buttons_form').submit();
});
$('#finish_invoice_quote_button').click(function() {
window.notifyCustomerDisplay();
$('#buttons_form').attr('action', "<?= "$controller_name/complete" ?>");
$('#buttons_form').submit();
});
$('#suspend_sale_button').click(function() {
window.notifyCustomerDisplay();
$('#buttons_form').attr('action', "<?= site_url("$controller_name/suspend") ?>");
$('#buttons_form').submit();
});
$('#cancel_sale_button').click(function() {
if (confirm("<?= lang(ucfirst($controller_name) . '.confirm_cancel_sale') ?>")) {
window.notifyCustomerDisplay();
$('#buttons_form').attr('action', "<?= site_url("$controller_name/cancel") ?>");
$('#buttons_form').submit();
}
});
$('#add_payment_button').click(function() {
window.notifyCustomerDisplay();
$('#add_payment_form').find('input[name="complete_after_payment"]').val('0');
$('#add_payment_form').submit();
});
@@ -919,13 +803,11 @@ helper('url');
if (response.success) {
if (resource.match(/customers$/)) {
$('#customer').val(response.id);
window.notifyCustomerDisplay();
$('#select_customer_form').submit();
} else {
var $stock_location = $("select[name='stock_location']").val();
$('#item_location').val($stock_location);
$('#item').val(response.id);
window.notifyCustomerDisplay();
if (stay_open) {
$('#add_item_form').ajaxSubmit();
} else {
@@ -948,17 +830,10 @@ helper('url');
function check_payment_type() {
var cash_mode = <?= json_encode($cash_mode) ?>;
const updateSecondaryRows = function(totalDisplay, amountDueDisplay) {
if (totalDisplay !== null && amountDueDisplay !== null) {
$("#sale_total_secondary_currency").html(totalDisplay);
$("#sale_amount_due_secondary_currency").html(amountDueDisplay);
}
};
if ($("#payment_types").val() == "<?= lang(ucfirst($controller_name) . '.giftcard') ?>") {
$("#sale_total").html("<?= to_currency($total) ?>");
$("#sale_amount_due").html("<?= to_currency($amount_due) ?>");
updateSecondaryRows(secondaryAmounts.total, secondaryAmounts.amountDue);
$("#amount_tendered_label").html("<?= lang(ucfirst($controller_name) . '.giftcard_number') ?>");
$("#amount_tendered:enabled").val('').focus();
$(".giftcard-input").attr('disabled', false);
@@ -967,7 +842,6 @@ helper('url');
} else if (($("#payment_types").val() == "<?= lang(ucfirst($controller_name) . '.cash') ?>" && cash_mode == '1')) {
$("#sale_total").html("<?= to_currency($non_cash_total) ?>");
$("#sale_amount_due").html("<?= to_currency($cash_amount_due) ?>");
updateSecondaryRows(secondaryAmounts.nonCashTotal, secondaryAmounts.cashAmountDue);
$("#amount_tendered_label").html("<?= lang(ucfirst($controller_name) . '.amount_tendered') ?>");
$("#amount_tendered:enabled").val("<?= to_currency_no_money($cash_amount_due) ?>");
$(".giftcard-input").attr('disabled', true);
@@ -975,7 +849,6 @@ helper('url');
} else {
$("#sale_total").html("<?= to_currency($non_cash_total) ?>");
$("#sale_amount_due").html("<?= to_currency($amount_due) ?>");
updateSecondaryRows(secondaryAmounts.nonCashTotal, secondaryAmounts.nonCashAmountDue);
$("#amount_tendered_label").html("<?= lang(ucfirst($controller_name) . '.amount_tendered') ?>");
$("#amount_tendered:enabled").val("<?= to_currency_no_money($amount_due) ?>");
$(".giftcard-input").attr('disabled', true);
@@ -988,7 +861,6 @@ helper('url');
if ($(event.target).closest('.modal').length || $('.modal.in').length) {
return;
}
if (event.altKey) {
switch (event.keyCode) {
case shortcutCodes.items:
@@ -1037,6 +909,3 @@ helper('url');
</script>
<?= view('partial/footer') ?>