Compare commits

..

26 Commits

Author SHA1 Message Date
Ollama
2688406020 fix: escape special characters in passwords for sed
Base64 passwords can contain /, +, = which break sed with |
delimiter. Also escape & and \ characters for safety.
2026-05-18 15:09:59 +02:00
Ollama
3366a38591 fix: address remaining CodeRabbit issues
- Add SQL injection validation for DB_NAME, DB_USER, DB_HOST
- Honor OSPOS_DIR during extraction (was hardcoded to /var/www)
- Fix SSL_EMAIL alone enabling Let's Encrypt (use APACHE_SERVER_NAME as domain)
- Quote OSPOS_DIR variables to prevent word splitting
2026-05-18 15:09:58 +02:00
Ollama
32d65da4f4 fix: address CodeRabbit review comments
- Remove duplicate php-gd package
- Disable directory listing (Options -Indexes)
- Propagate MYSQL_ROOT_PASS to all mysql commands
- Fix allowedHostnames sed pattern to match .env.example format
2026-05-18 15:09:58 +02:00
Ollama
d76f45294f docs: add missing env vars and testing note to INSTALL.md 2026-05-18 15:09:58 +02:00
Ollama
88dba77226 chore: remove debug output after fix verification 2026-05-18 15:09:58 +02:00
Ollama
dee9661210 fix: use | delimiter in sed to handle special characters in passwords
The / delimiter was breaking when passwords contain special chars like !
2026-05-18 15:09:58 +02:00
Ollama
cc69015771 fix: copy hidden files from extraction directory
Hidden files like .env were not being copied because shell glob *
doesn't match hidden files. Use 'ospos-temp/.' to copy all files.
2026-05-18 15:09:58 +02:00
Ollama
3281438d4c fix: add debug output to trace .env configuration 2026-05-18 15:09:58 +02:00
Ollama
5aed26b66e fix: add verification output for .env configuration 2026-05-18 15:09:58 +02:00
Ollama
4e48209ed8 fix: generate encryption key for OSPOS
The release .env has an empty encryption key which causes
HTTP 500 on startup. Generate a random key if the value is empty.
2026-05-18 15:09:58 +02:00
Ollama
a69e6bd38b ci: improve debugging for .env and PHP errors 2026-05-18 15:09:58 +02:00
Ollama
9d035a73ac fix: match quoted values in .env file for sed substitutions
The .env file from release uses quoted values like 'localhost'
but sed patterns were looking for unquoted values, causing
database credentials to not be updated.
2026-05-18 15:09:58 +02:00
Ollama
8dba7816ea fix: configure .env even when .env.example doesn't exist
Release zip contains .env directly, not .env.example. The sed commands
to update database credentials were being skipped because the file check
looked for .env.example first, which doesn't exist in published releases.
2026-05-18 15:09:58 +02:00
Ollama
ff30295adc ci: add more detailed debugging for HTTP 500 errors 2026-05-18 15:09:58 +02:00
Ollama
3819d5ff65 ci: add debug logging for HTTP 500 errors 2026-05-18 15:09:58 +02:00
Ollama
a9ebf8562c fix: extract release zip directly to OSPOS directory
The release zip files extract at root level without a subdirectory
2026-05-18 15:09:58 +02:00
Ollama
4334542efa fix: remove hardcoded OSPOS_VERSION from CI workflow
Let the install script use the latest release by default
2026-05-18 15:09:58 +02:00
Ollama
1901043065 fix: use correct release asset URL from GitHub API
Releases use opensourcepos.VERSION.HASH.zip naming format, not
opensourcepos-VERSION.zip. This fix fetches the actual asset URL
from the GitHub API and extracts the correct directory name.
2026-05-18 15:09:58 +02:00
Ollama
2b90929ed4 fix: add ondrej/php PPA when PHP version not in default repos
The install script was failing on Ubuntu 22.04 because PHP 8.2 is not
available in default repositories. This fix checks if the requested
PHP version is available, and if not, adds the ondrej/php PPA which
provides all supported PHP versions.
2026-05-18 15:09:58 +02:00
Ollama
e363ff4bc0 ci: add install script test workflow
- Tests install script on Ubuntu 22.04 runner
- Verifies Apache, MariaDB, and OSPOS services
- Matrix tests default and custom DB_PASS scenarios
- Uploads install logs as artifacts
2026-05-18 15:09:58 +02:00
Ollama
c87b51a698 Add interactive SSL configuration prompt
- Prompts user for SSL preferences during installation
- Asks for domain name and email interactively
- Falls back to environment variables for non-interactive mode
- Shows SSL status in final output (Let's Encrypt / self-signed / none)
- Updates INSTALL.md with interactive/non-interactive examples

Interactive mode (recommended):
  curl -sSL https://opensourcepos.org/install | sudo bash
  # Prompts for SSL, domain, and email

Non-interactive mode:
  curl -sSL https://opensourcepos.org/install | \
    APACHE_SERVER_NAME=pos.example.com \
    SSL_EMAIL=admin@example.com \
    sudo -E bash
2026-05-18 15:09:58 +02:00
Ollama
f3c0c2ea8f Add automatic SSL/TLS certificate setup
- Adds Let's Encrypt support for production (with auto-renewal via certbot.timer)
- Falls back to self-signed certificate for development/testing
- New SSL_EMAIL environment variable enables production SSL
- HTTPS redirect automatically configured for all sites
- Updates INSTALL.md with SSL documentation and examples

Production usage:
  SSL_EMAIL=admin@example.com APACHE_SERVER_NAME=pos.example.com

Development usage (self-signed cert):
  APACHE_SERVER_NAME=localhost (default)
2026-05-18 15:09:58 +02:00
Ollama
59feca7ece Download latest stable release instead of master branch
- Fetches latest release version from GitHub API
- Downloads pre-built release zip instead of cloning repo
- Renamed OSPOS_BRANCH to OSPOS_VERSION for clarity
- Supports installing specific version via OSPOS_VERSION
- Removed need for composer install (release is pre-built)
- More stable for production deployments
2026-05-18 15:09:58 +02:00
Ollama
19c2a7cf08 Update Cloud Install section to recommend one-line installer
- Keep DigitalOcean referral link ($100 credit)
- Simplify instructions to 3 steps: create droplet, SSH, run installer
- Move one-line installation section into Cloud Install
- Add security reminder to change password and configure SSL
- Retain link to wiki for manual installation options
2026-05-18 15:09:58 +02:00
jekkos
c26d18d014 Update INSTALL.md with opensourcepos.org short URL
- Preferred install URL: https://opensourcepos.org/install
- Falls back to direct GitHub URL if redirect unavailable
- More professional and easier to remember
2026-05-18 15:09:58 +02:00
jekkos
1a112a1fd1 Add one-line Ubuntu installation script
- Creates scripts/install-ubuntu.sh for automated fresh Ubuntu server setup
- Installs Apache, MariaDB, PHP 8.2 with required extensions
- Downloads and configures OSPOS from GitHub
- Sets up Apache virtual host with proper permissions
- Generates secure random database password
- Supports environment variables for customization
- Updates INSTALL.md with curl pipe to bash instructions

This provides an alternative to cloud-specific instructions and
allows users to quickly set up OSPOS on any fresh Ubuntu server.
2026-05-18 15:09:58 +02:00
21 changed files with 2022 additions and 3505 deletions

View File

@@ -0,0 +1,130 @@
name: Install Script Test
on:
push:
paths:
- 'scripts/install-ubuntu.sh'
- '.github/workflows/install-script-test.yml'
pull_request:
paths:
- 'scripts/install-ubuntu.sh'
- '.github/workflows/install-script-test.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
install-test:
name: Test Install Script (${{ matrix.scenario }})
runs-on: ubuntu-22.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- scenario: default
db_pass: ''
- scenario: custom-password
db_pass: 'TestPass123!'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Make install script executable
run: chmod +x scripts/install-ubuntu.sh
- name: Run install script
env:
DB_PASS: ${{ matrix.db_pass }}
run: |
echo "Running install script with scenario: ${{ matrix.scenario }}"
sudo -E bash scripts/install-ubuntu.sh 2>&1 | tee install-output.log
echo "Install completed successfully"
- name: Wait for services to stabilize
run: sleep 10
- name: Verify Apache is running
run: |
echo "Checking Apache status..."
sudo systemctl status apache2 --no-pager
sudo systemctl is-active apache2
- name: Verify MariaDB is running
run: |
echo "Checking MariaDB status..."
sudo systemctl status mariadb --no-pager
sudo systemctl is-active mariadb
- name: Verify Apache HTTP response
run: |
echo "Testing HTTP response on port 80..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/)
echo "HTTP Response Code: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then
echo "Apache is responding correctly"
elif [ "$HTTP_CODE" = "500" ]; then
echo "HTTP 500 - Application error. Checking .env configuration..."
sudo cat /var/www/ospos/.env 2>/dev/null | grep -E "database\.default\.(hostname|database|username|password)|encryption\.key|CI_ENVIRONMENT" | head -10
sudo cat /var/www/ospos/writable/logs/*.log 2>/dev/null | tail -20 || true
curl -s http://localhost/ | head -50
exit 1
else
echo "Unexpected HTTP code: $HTTP_CODE"
exit 1
fi
- name: Verify OSPOS login page
run: |
echo "Checking OSPOS login page..."
curl -s http://localhost/ | grep -qi "login\|password\|username" && echo "Login page content found" || {
echo "Login page verification failed"
curl -s http://localhost/ | head -50
exit 1
}
- name: Verify database exists
env:
DB_PASS: ${{ matrix.db_pass != '' && matrix.db_pass || '' }}
run: |
echo "Verifying database..."
# Extract the generated password from install output if using default
if [ -z "${{ matrix.db_pass }}" ]; then
GENERATED_PASS=$(grep -oP 'Database Password: \K[^\s]+' install-output.log || true)
if [ -n "$GENERATED_PASS" ]; then
DB_PASS="$GENERATED_PASS"
fi
fi
# Check database exists
sudo mysql -u root -e "SHOW DATABASES LIKE 'ospos';" | grep -q ospos && echo "Database 'ospos' exists" || {
echo "Database 'ospos' not found"
sudo mysql -u root -e "SHOW DATABASES;"
exit 1
}
# Check tables exist
TABLE_COUNT=$(sudo mysql -u root ospos -e "SHOW TABLES;" | wc -l)
echo "Found $TABLE_COUNT tables in database"
if [ "$TABLE_COUNT" -gt 5 ]; then
echo "Database tables verified"
else
echo "Not enough tables found"
exit 1
fi
- name: Upload install log
uses: actions/upload-artifact@v4
if: always()
with:
name: install-log-${{ matrix.scenario }}
path: install-output.log
retention-days: 7

View File

@@ -102,5 +102,73 @@ Do **not** use below command on live deployments unless you want to tear everyth
## Cloud install
If you choose DigitalOcean:
[Through this link](https://m.do.co/c/ac38c262507b), you will get a [**free $100, 60-day credit**](https://m.do.co/c/ac38c262507b). [Check the wiki](https://github.com/opensourcepos/opensourcepos/wiki/Getting-Started-installations) for further instructions on how to install the necessary components.
### Recommended: DigitalOcean
Sign up through [our referral link](https://m.do.co/c/ac38c262507b) to get a [**$100, 60-day credit**](https://m.do.co/c/ac38c262507b).
1. Create an Ubuntu 20.04+ or 22.04+ droplet
2. SSH into your server: `ssh root@<your-droplet-ip>`
3. Run the one-line installer:
```bash
curl -sSL https://opensourcepos.org/install | sudo bash
```
The installer will:
- Install Apache, MariaDB, PHP 8.2 and required extensions
- Download the **latest stable release** of OSPOS from GitHub
- Create a database with secure random password
- Configure OSPOS and Apache
- **Set up SSL/TLS certificates** (interactive prompt or environment variables)
- Display login credentials after completion
**Interactive Mode (Recommended for first-time users):**
When run without environment variables, the installer will prompt you:
1. Whether to configure SSL (recommended for production)
2. Your domain name (e.g., `pos.example.com`)
3. Your email for Let's Encrypt (for production SSL)
```bash
curl -sSL https://opensourcepos.org/install | sudo bash
# Script will ask:
# - Configure SSL? (y/n)
# - Domain name: pos.example.com
# - Email for Let's Encrypt: admin@example.com
```
**Non-Interactive Mode (for automation):**
```bash
# Development (no SSL)
curl -sSL https://opensourcepos.org/install | APACHE_SERVER_NAME=localhost sudo -E bash
# Production with Let's Encrypt SSL
curl -sSL https://opensourcepos.org/install | APACHE_SERVER_NAME=pos.example.com SSL_EMAIL=admin@example.com sudo -E bash
# Custom database password
curl -sSL https://opensourcepos.org/install | DB_PASS=securepassword APACHE_SERVER_NAME=pos.example.com SSL_EMAIL=admin@example.com sudo -E bash
```
**Environment variables:**
- `DB_HOST` - Database host (default: localhost)
- `DB_NAME` - Database name (default: ospos)
- `DB_USER` - Database user (default: ospos)
- `DB_PASS` - Database password (default: auto-generated)
- `MYSQL_ROOT_PASS` - MariaDB root password (default: empty/no password)
- `OSPOS_DIR` - Installation directory (default: /var/www/ospos)
- `OSPOS_VERSION` - OSPOS version to install (default: latest stable release)
- `PHP_VERSION` - PHP version (default: 8.2)
- `APACHE_SERVER_NAME` - Server hostname (default: localhost, or set interactively)
- `SSL_EMAIL` - Email for Let's Encrypt. When set, enables production SSL with auto-renewal
- `SSL_DOMAIN` - Alternative to `APACHE_SERVER_NAME` for SSL certificate domain
> **Testing:** This installer is tested with each commit via our CI workflow. A fresh Ubuntu container is spawned, the script runs to completion, and basic sanity checks verify the installation. For production deployments, we recommend testing on a staging server first. If you encounter issues, please [open an issue](https://github.com/opensourcepos/opensourcepos/issues/new?template=bug_report.yml) with your server version and error output.
> **Note:** If the short URL is unavailable, use the direct GitHub URL:
> ```bash
> curl -sSL https://raw.githubusercontent.com/opensourcepos/opensourcepos/master/scripts/install-ubuntu.sh | sudo bash
> ```
For other cloud providers or manual installation, see the [detailed installation guide](https://github.com/opensourcepos/opensourcepos/wiki/Getting-Started-installations) in the wiki.
**Important:** Change the default password after first login!

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddUBLConfig extends Migration
{
public function up(): void
{
log_message('info', 'Adding UBL configuration.');
$config_values = [
['key' => 'invoice_format', 'value' => 'pdf_only'],
];
$this->db->table('app_config')->ignore(true)->insertBatch($config_values);
}
public function down(): void
{
$this->db->table('app_config')->whereIn('key', ['invoice_format'])->delete();
}
}

View File

@@ -1,229 +0,0 @@
<?php
use Config\OSPOS;
/**
* Country code helper for mapping country names to ISO 3166-1 alpha-2 codes
*/
if (!function_exists('getCountryCode')) {
/**
* Convert country name to ISO 3166-1 alpha-2 code
*
* @param string $countryName Country name (full name in English)
* @return string ISO 3166-1 alpha-2 code, or 'BE' as default for Belgium
*/
function getCountryCode(string $countryName): string
{
if (empty($countryName)) {
return 'BE'; // Default to Belgium
}
$countryMap = [
// Major countries
'Belgium' => 'BE',
'Belgique' => 'BE',
'België' => 'BE',
'United States' => 'US',
'USA' => 'US',
'United States of America' => 'US',
'United Kingdom' => 'GB',
'UK' => 'GB',
'Great Britain' => 'GB',
'France' => 'FR',
'Germany' => 'DE',
'Deutschland' => 'DE',
'Netherlands' => 'NL',
'The Netherlands' => 'NL',
'Nederland' => 'NL',
'Italy' => 'IT',
'Italia' => 'IT',
'Spain' => 'ES',
'España' => 'ES',
'Poland' => 'PL',
'Polska' => 'PL',
'Portugal' => 'PT',
'Sweden' => 'SE',
'Sverige' => 'SE',
'Norway' => 'NO',
'Norge' => 'NO',
'Denmark' => 'DK',
'Danmark' => 'DK',
'Finland' => 'FI',
'Suomi' => 'FI',
'Switzerland' => 'CH',
'Suisse' => 'CH',
'Schweiz' => 'CH',
'Austria' => 'AT',
'Österreich' => 'AT',
'Ireland' => 'IE',
'Luxembourg' => 'LU',
'Greece' => 'GR',
'Czech Republic' => 'CZ',
'Czechia' => 'CZ',
'Hungary' => 'HU',
'Romania' => 'RO',
'Bulgaria' => 'BG',
'Slovakia' => 'SK',
'Slovenia' => 'SI',
'Estonia' => 'EE',
'Latvia' => 'LV',
'Lithuania' => 'LT',
'Croatia' => 'HR',
'Serbia' => 'RS',
'Montenegro' => 'ME',
'Bosnia and Herzegovina' => 'BA',
'North Macedonia' => 'MK',
'Albania' => 'AL',
'Kosovo' => 'XK',
'Turkey' => 'TR',
'Türkiye' => 'TR',
'Russia' => 'RU',
'Russian Federation' => 'RU',
'Ukraine' => 'UA',
'Belarus' => 'BY',
'Moldova' => 'MD',
'Georgia' => 'GE',
'Armenia' => 'AM',
'Azerbaijan' => 'AZ',
'Kazakhstan' => 'KZ',
'Uzbekistan' => 'UZ',
// Other major economies
'China' => 'CN',
'Japan' => 'JP',
'South Korea' => 'KR',
'Korea' => 'KR',
'India' => 'IN',
'Australia' => 'AU',
'New Zealand' => 'NZ',
'Canada' => 'CA',
'Mexico' => 'MX',
'Brazil' => 'BR',
'Argentina' => 'AR',
'Chile' => 'CL',
'Colombia' => 'CO',
'Peru' => 'PE',
'South Africa' => 'ZA',
'Egypt' => 'EG',
'Nigeria' => 'NG',
'Kenya' => 'KE',
'Morocco' => 'MA',
// If already ISO code, return as-is
'BE' => 'BE',
'US' => 'US',
'GB' => 'GB',
'FR' => 'FR',
'DE' => 'DE',
'NL' => 'NL',
'IT' => 'IT',
'ES' => 'ES',
'PT' => 'PT',
'SE' => 'SE',
'NO' => 'NO',
'DK' => 'DK',
'FI' => 'FI',
'CH' => 'CH',
'AT' => 'AT',
'IE' => 'IE',
'LU' => 'LU',
'GR' => 'GR',
'CZ' => 'CZ',
'HU' => 'HU',
'RO' => 'RO',
'BG' => 'BG',
'SK' => 'SK',
'SI' => 'SI',
'EE' => 'EE',
'LV' => 'LV',
'LT' => 'LT',
'HR' => 'HR',
'RS' => 'RS',
'ME' => 'ME',
'BA' => 'BA',
'MK' => 'MK',
'AL' => 'AL',
'TR' => 'TR',
'RU' => 'RU',
'UA' => 'UA',
];
// Try exact match first
$normalized = trim($countryName);
if (isset($countryMap[$normalized])) {
return $countryMap[$normalized];
}
// Try case-insensitive match
$normalizedLower = strtolower($normalized);
foreach ($countryMap as $key => $code) {
if (strtolower($key) === $normalizedLower) {
return $code;
}
}
// Try partial match (e.g., "United States" → "US")
foreach ($countryMap as $key => $code) {
if (stripos($key, $normalized) !== false || stripos($normalized, $key) !== false) {
return $code;
}
}
// Try matching ISO code directly
if (preg_match('/^[A-Z]{2}$/i', $normalized)) {
return strtoupper($normalized);
}
// Check if the country_codes config has a default
$config = config(OSPOS::class)->settings;
if (isset($config['country_codes']) && !empty($config['country_codes'])) {
$countries = explode(',', $config['country_codes']);
if (!empty($countries)) {
return strtoupper(trim($countries[0]));
}
}
// Default to Belgium (for Peppol compliance in Belgium)
return 'BE';
}
}
if (!function_exists('getCurrencyCode')) {
/**
* Get ISO 4217 currency code for a country
*
* @param string $countryCode ISO 3166-1 alpha-2 country code
* @return string ISO 4217 currency code
*/
function getCurrencyCode(string $countryCode): string
{
$currencyMap = [
'BE' => 'EUR',
'FR' => 'EUR',
'DE' => 'EUR',
'NL' => 'EUR',
'IT' => 'EUR',
'ES' => 'EUR',
'PT' => 'EUR',
'IE' => 'EUR',
'AT' => 'EUR',
'LU' => 'EUR',
'FI' => 'EUR',
'GR' => 'EUR',
'US' => 'USD',
'GB' => 'GBP',
'CH' => 'CHF',
'JP' => 'JPY',
'CN' => 'CNY',
'CA' => 'CAD',
'AU' => 'AUD',
'NZ' => 'NZD',
'IN' => 'INR',
'BR' => 'BRL',
'MX' => 'MXN',
'ZA' => 'ZAR',
];
return $currencyMap[$countryCode] ?? 'EUR'; // Default to EUR
}
}

View File

@@ -85,7 +85,6 @@ function get_sales_manage_table_headers(): string
if ($config['invoice_enable']) {
$headers[] = ['invoice_number' => lang('Sales.invoice_number')];
$headers[] = ['invoice' => '', 'sortable' => false, 'escape' => false];
$headers[] = ['ubl' => '', 'sortable' => false, 'escape' => false];
}
$headers[] = ['receipt' => '', 'sortable' => false, 'escape' => false];
@@ -122,13 +121,6 @@ function get_sale_data_row(object $sale): array
'<span class="glyphicon glyphicon-list-alt"></span>',
['title' => lang('Sales.show_invoice')]
);
$row['ubl'] = empty($sale->invoice_number)
? '-'
: anchor(
"$controller/ublInvoice/$sale->sale_id",
'<span class="glyphicon glyphicon-download"></span>',
['title' => lang('Sales.download_ubl'), 'target' => '_blank']
);
}
$row['receipt'] = anchor(

View File

@@ -1,237 +1,234 @@
<?php
return [
'customers_available_points' => 'Points Available',
'rewards_package' => 'Rewards',
'rewards_remaining_balance' => 'Reward Points remaining value is ',
'account_number' => 'Account #',
'add_payment' => 'Add Payment',
'amount_due' => 'Amount Due',
'amount_tendered' => 'Amount Tendered',
'authorized_signature' => 'Authorized Signature',
'bank_transfer' => 'Bank Transfer',
'cancel_sale' => 'Cancel',
'cash' => 'Cash',
'cash_1' => '',
'cash_2' => '',
'cash_3' => '',
'cash_4' => '',
'cash_adjustment' => 'Cash Adjustment',
'cash_deposit' => 'Cash Deposit',
'cash_filter' => 'Cash',
'change_due' => 'Change Due',
'change_price' => 'Change Selling Price',
'check' => 'Check',
'check_balance' => 'Check remainder',
'check_filter' => 'Check',
'close' => '',
'comment' => 'Comment',
'comments' => 'Comments',
'company_name' => '',
'complete' => '',
'complete_sale' => 'Complete',
'confirm_cancel_sale' => 'Are you sure you want to clear this sale? All items will be cleared.',
'confirm_delete' => 'Are you sure you want to delete the selected Sale(s)?',
'confirm_restore' => 'Are you sure you want to restore the selected Sale(s)?',
'credit' => 'Credit Card',
'credit_deposit' => 'Credit Deposit',
'credit_filter' => 'Credit Card',
'current_table' => '',
'customer' => 'Customer',
'customer_address' => 'Address',
'customer_discount' => 'Discount',
'customer_email' => 'Email',
'customer_location' => 'Location',
'customer_mailchimp_status' => 'MailChimp Status',
'customer_optional' => '(Required for Due Payments)',
'customer_required' => '(Required)',
'customer_total' => 'Total',
'customer_total_spent' => '',
'daily_sales' => '',
'date' => 'Sale Date',
'date_range' => 'Date Range',
'date_required' => 'A correct date must be entered.',
'date_type' => 'Date is a required field.',
'debit' => 'Debit Card',
'debit_filter' => '',
'delete' => 'Allow Delete',
'delete_confirmation' => 'Are you sure you want to delete this sale? This action cannot be undone.',
'delete_entire_sale' => 'Delete Entire Sale',
'delete_successful' => 'Sale delete successful.',
'delete_unsuccessful' => 'Sale delete failed.',
'description_abbrv' => 'Desc.',
'discard' => 'Discard',
'discard_quote' => '',
'discount' => 'Disc',
'discount_included' => '% Discount',
'discount_short' => '%',
'due' => 'Due',
'due_filter' => 'Due',
'edit' => 'Edit',
'edit_item' => 'Edit Item',
'edit_sale' => 'Edit Sale',
'email_receipt' => 'Email Receipt',
'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',
'giftcard_balance' => 'Gift Card Balance',
'giftcard_filter' => '',
'giftcard_number' => 'Gift Card Number',
'group_by_category' => 'Group by Category',
'group_by_type' => 'Group by Type',
'hsn' => 'HSN',
'id' => 'Sale ID',
'include_prices' => 'Include Prices?',
'invoice' => 'Invoice',
'invoice_confirm' => 'This invoice will be sent to',
'invoice_enable' => 'Invoice Number',
'invoice_filter' => 'Invoices',
'invoice_no_email' => 'This customer does not have a valid email address.',
'invoice_number' => 'Invoice #',
'invoice_number_duplicate' => 'Invoice Number {0} must be unique.',
'invoice_sent' => 'Invoice sent to',
'invoice_total' => 'Invoice Total',
'invoice_type_custom_invoice' => 'Custom Invoice (custom_invoice.php)',
'invoice_type_custom_tax_invoice' => 'Custom Tax Invoice (custom_tax_invoice.php)',
'invoice_type_invoice' => 'Invoice (invoice.php)',
'invoice_type_tax_invoice' => 'Tax Invoice (tax_invoice.php)',
'invoice_unsent' => 'Invoice failed to be sent to',
'invoice_update' => 'Recount',
'item_insufficient_of_stock' => 'Item has insufficient stock.',
'item_name' => 'Item Name',
'item_number' => 'Item #',
'item_out_of_stock' => 'Item is out of stock.',
'key_browser' => 'Helpful Shortcuts',
'key_cancel' => 'Cancels Current Quote/Invoice/Sale',
'key_customer_search' => 'Customer Search',
'key_finish_quote' => 'Finish Quote/Invoice without payment',
'key_finish_sale' => 'Add Payment and Complete Invoice/Sale',
'key_full' => 'Open in Full Screen Mode',
'key_function' => 'Function',
'key_help' => 'Shortcuts',
'key_help_modal' => 'Open Shortcuts Window',
'key_in' => 'Zoom in',
'key_item_search' => 'Item Search',
'key_out' => 'Zoom Out',
'key_payment' => 'Add Payment',
'key_print' => 'Print Current Page',
'key_restore' => 'Restore Original Display/Zoom',
'key_search' => 'Search Reports Tables',
'key_suspend' => 'Suspend Current Sale',
'key_suspended' => 'Show Suspended Sales',
'key_system' => 'System Shortcuts',
'key_tendered' => 'Edit Amount Tendered',
'key_title' => 'Sales Keyboard Shortcuts',
'mc' => '',
'mode' => 'Register Mode',
'must_enter_numeric' => 'Amount Tendered must be a number.',
'must_enter_numeric_giftcard' => 'Gift Card Number must be a number.',
'new_customer' => 'New Customer',
'new_item' => 'New Item',
'no_description' => 'No description',
'no_filter' => 'All',
'no_items_in_cart' => 'There are no Items in the cart.',
'no_sales_to_display' => 'No Sales to display.',
'none_selected' => 'You have not selected any Sale(s) to delete.',
'nontaxed_ind' => ' ',
'not_authorized' => 'This action is not authorized.',
'one_or_multiple' => 'Sale(s)',
'payment' => 'Payment Type',
'payment_amount' => 'Amount',
'payment_not_cover_total' => 'Payment Amount must be greater than or equal to Total.',
'payment_type' => 'Type',
'payments' => '',
'payments_total' => 'Payments Total',
'price' => 'Price',
'print_after_sale' => 'Print after Sale',
'quantity' => 'Quantity',
'quantity_less_than_reorder_level' => 'Warning: Desired Quantity is below Reorder Level for that Item.',
'quantity_less_than_zero' => 'Warning: Desired Quantity is insufficient. You can still process the sale, but audit your inventory.',
'quantity_of_items' => 'Quantity of {0} Items',
'quote' => 'Quote',
'quote_number' => 'Quote Number',
'quote_number_duplicate' => 'Quote Number must be unique.',
'quote_sent' => 'Quote sent to',
'quote_unsent' => 'Quote failed to be sent to',
'receipt' => 'Sales Receipt',
'receipt_no_email' => 'This customer does not have a valid email address.',
'receipt_number' => 'Sale #',
'receipt_sent' => 'Receipt sent to',
'receipt_unsent' => 'Receipt failed to be sent to',
'refund' => 'Refund Type',
'register' => 'Sales Register',
'remove_customer' => 'Remove Customer',
'remove_discount' => '',
'return' => 'Return',
'rewards' => 'Reward Points',
'rewards_balance' => 'Reward Points Balance',
'sale' => 'Sale',
'sale_by_invoice' => 'Sale by Invoice',
'sale_for_customer' => 'Customer:',
'sale_time' => 'Time',
'sales_tax' => 'Sales Tax',
'sales_total' => '',
'select_customer' => 'Select Customer',
'selected_customer' => 'Selected Customer',
'send_invoice' => 'Send Invoice',
'send_quote' => 'Send Quote',
'send_receipt' => 'Send Receipt',
'send_work_order' => 'Send Work Order',
'serial' => 'Serial',
'service_charge' => '',
'show_due' => '',
'show_invoice' => 'Show Invoice',
'show_receipt' => 'Show Receipt',
'start_typing_customer_name' => 'Start typing customer details...',
'start_typing_item_name' => 'Start typing Item Name or scan Barcode...',
'stock' => 'Stock',
'stock_location' => 'Stock Location',
'sub_total' => 'Subtotal',
'successfully_deleted' => 'You have successfully deleted',
'successfully_restored' => 'You have successfully restored',
'successfully_suspended_sale' => 'Sale suspend successful.',
'successfully_updated' => 'Sale update successful.',
'suspend_sale' => 'Suspend',
'suspended_doc_id' => 'Document',
'suspended_sale_id' => 'ID',
'suspended_sales' => 'Suspended',
'table' => 'Table',
'takings' => 'Daily Sales',
'tax' => 'Tax',
'tax_id' => 'Tax Id',
'tax_invoice' => 'Tax Invoice',
'tax_percent' => 'Tax %',
'taxed_ind' => 'T',
'total' => 'Total',
'total_tax_exclusive' => 'Tax excluded',
'transaction_failed' => 'Sales Transaction failed.',
'unable_to_add_item' => 'Item add to Sale failed',
'unsuccessfully_deleted' => 'Sale(s) delete failed.',
'unsuccessfully_restored' => 'Sale(s) restore failed.',
'unsuccessfully_suspended_sale' => 'Sale suspend failed.',
'unsuccessfully_updated' => 'Sale update failed.',
'unsuspend' => 'Unsuspend',
'unsuspend_and_delete' => 'Action',
'update' => 'Update',
'upi' => 'UPI',
'visa' => '',
'wallet' => 'Wallet',
'wholesale' => '',
'work_order' => 'Work Order',
'work_order_number' => 'Work Order Number',
'work_order_number_duplicate' => 'Work Order Number must be unique.',
'work_order_sent' => 'Work Order sent to',
'work_order_unsent' => 'Work Order failed to be sent to',
'download_ubl' => 'Download UBL Invoice',
'ubl_generation_failed' => 'UBL invoice generation failed',
'sale_not_found' => 'Sale not found',
"customers_available_points" => "Points Available",
"rewards_package" => "Rewards",
"rewards_remaining_balance" => "Reward Points remaining value is ",
"account_number" => "Account #",
"add_payment" => "Add Payment",
"amount_due" => "Amount Due",
"amount_tendered" => "Amount Tendered",
"authorized_signature" => "Authorized Signature",
"bank_transfer" => "Bank Transfer",
"cancel_sale" => "Cancel",
"cash" => "Cash",
"cash_1" => "",
"cash_2" => "",
"cash_3" => "",
"cash_4" => "",
"cash_adjustment" => "Cash Adjustment",
"cash_deposit" => "Cash Deposit",
"cash_filter" => "Cash",
"change_due" => "Change Due",
"change_price" => "Change Selling Price",
"check" => "Check",
"check_balance" => "Check remainder",
"check_filter" => "Check",
"close" => "",
"comment" => "Comment",
"comments" => "Comments",
"company_name" => "",
"complete" => "",
"complete_sale" => "Complete",
"confirm_cancel_sale" => "Are you sure you want to clear this sale? All items will be cleared.",
"confirm_delete" => "Are you sure you want to delete the selected Sale(s)?",
"confirm_restore" => "Are you sure you want to restore the selected Sale(s)?",
"credit" => "Credit Card",
"credit_deposit" => "Credit Deposit",
"credit_filter" => "Credit Card",
"current_table" => "",
"customer" => "Customer",
"customer_address" => "Address",
"customer_discount" => "Discount",
"customer_email" => "Email",
"customer_location" => "Location",
"customer_mailchimp_status" => "MailChimp Status",
"customer_optional" => "(Required for Due Payments)",
"customer_required" => "(Required)",
"customer_total" => "Total",
"customer_total_spent" => "",
"daily_sales" => "",
"date" => "Sale Date",
"date_range" => "Date Range",
"date_required" => "A correct date must be entered.",
"date_type" => "Date is a required field.",
"debit" => "Debit Card",
"debit_filter" => "",
"delete" => "Allow Delete",
"delete_confirmation" => "Are you sure you want to delete this sale? This action cannot be undone.",
"delete_entire_sale" => "Delete Entire Sale",
"delete_successful" => "Sale delete successful.",
"delete_unsuccessful" => "Sale delete failed.",
"description_abbrv" => "Desc.",
"discard" => "Discard",
"discard_quote" => "",
"discount" => "Disc",
"discount_included" => "% Discount",
"discount_short" => "%",
"due" => "Due",
"due_filter" => "Due",
"edit" => "Edit",
"edit_item" => "Edit Item",
"edit_sale" => "Edit Sale",
"email_receipt" => "Email Receipt",
"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",
"giftcard_balance" => "Gift Card Balance",
"giftcard_filter" => "",
"giftcard_number" => "Gift Card Number",
"group_by_category" => "Group by Category",
"group_by_type" => "Group by Type",
"hsn" => "HSN",
"id" => "Sale ID",
"include_prices" => "Include Prices?",
"invoice" => "Invoice",
"invoice_confirm" => "This invoice will be sent to",
"invoice_enable" => "Invoice Number",
"invoice_filter" => "Invoices",
"invoice_no_email" => "This customer does not have a valid email address.",
"invoice_number" => "Invoice #",
"invoice_number_duplicate" => "Invoice Number {0} must be unique.",
"invoice_sent" => "Invoice sent to",
"invoice_total" => "Invoice Total",
"invoice_type_custom_invoice" => "Custom Invoice (custom_invoice.php)",
"invoice_type_custom_tax_invoice" => "Custom Tax Invoice (custom_tax_invoice.php)",
"invoice_type_invoice" => "Invoice (invoice.php)",
"invoice_type_tax_invoice" => "Tax Invoice (tax_invoice.php)",
"invoice_unsent" => "Invoice failed to be sent to",
"invoice_update" => "Recount",
"item_insufficient_of_stock" => "Item has insufficient stock.",
"item_name" => "Item Name",
"item_number" => "Item #",
"item_out_of_stock" => "Item is out of stock.",
"key_browser" => "Helpful Shortcuts",
"key_cancel" => "Cancels Current Quote/Invoice/Sale",
"key_customer_search" => "Customer Search",
"key_finish_quote" => "Finish Quote/Invoice without payment",
"key_finish_sale" => "Add Payment and Complete Invoice/Sale",
"key_full" => "Open in Full Screen Mode",
"key_function" => "Function",
"key_help" => "Shortcuts",
"key_help_modal" => "Open Shortcuts Window",
"key_in" => "Zoom in",
"key_item_search" => "Item Search",
"key_out" => "Zoom Out",
"key_payment" => "Add Payment",
"key_print" => "Print Current Page",
"key_restore" => "Restore Original Display/Zoom",
"key_search" => "Search Reports Tables",
"key_suspend" => "Suspend Current Sale",
"key_suspended" => "Show Suspended Sales",
"key_system" => "System Shortcuts",
"key_tendered" => "Edit Amount Tendered",
"key_title" => "Sales Keyboard Shortcuts",
"mc" => "",
"mode" => "Register Mode",
"must_enter_numeric" => "Amount Tendered must be a number.",
"must_enter_numeric_giftcard" => "Gift Card Number must be a number.",
"new_customer" => "New Customer",
"new_item" => "New Item",
"no_description" => "No description",
"no_filter" => "All",
"no_items_in_cart" => "There are no Items in the cart.",
"no_sales_to_display" => "No Sales to display.",
"none_selected" => "You have not selected any Sale(s) to delete.",
"nontaxed_ind" => " ",
"not_authorized" => "This action is not authorized.",
"one_or_multiple" => "Sale(s)",
"payment" => "Payment Type",
"payment_amount" => "Amount",
"payment_not_cover_total" => "Payment Amount must be greater than or equal to Total.",
"payment_type" => "Type",
"payments" => "",
"payments_total" => "Payments Total",
"price" => "Price",
"print_after_sale" => "Print after Sale",
"quantity" => "Quantity",
"quantity_less_than_reorder_level" => "Warning: Desired Quantity is below Reorder Level for that Item.",
"quantity_less_than_zero" => "Warning: Desired Quantity is insufficient. You can still process the sale, but audit your inventory.",
"quantity_of_items" => "Quantity of {0} Items",
"quote" => "Quote",
"quote_number" => "Quote Number",
"quote_number_duplicate" => "Quote Number must be unique.",
"quote_sent" => "Quote sent to",
"quote_unsent" => "Quote failed to be sent to",
"receipt" => "Sales Receipt",
"receipt_no_email" => "This customer does not have a valid email address.",
"receipt_number" => "Sale #",
"receipt_sent" => "Receipt sent to",
"receipt_unsent" => "Receipt failed to be sent to",
"refund" => "Refund Type",
"register" => "Sales Register",
"remove_customer" => "Remove Customer",
"remove_discount" => "",
"return" => "Return",
"rewards" => "Reward Points",
"rewards_balance" => "Reward Points Balance",
"sale" => "Sale",
"sale_by_invoice" => "Sale by Invoice",
"sale_for_customer" => "Customer:",
"sale_time" => "Time",
"sales_tax" => "Sales Tax",
"sales_total" => "",
"select_customer" => "Select Customer",
"selected_customer" => "Selected Customer",
"send_invoice" => "Send Invoice",
"send_quote" => "Send Quote",
"send_receipt" => "Send Receipt",
"send_work_order" => "Send Work Order",
"serial" => "Serial",
"service_charge" => "",
"show_due" => "",
"show_invoice" => "Show Invoice",
"show_receipt" => "Show Receipt",
"start_typing_customer_name" => "Start typing customer details...",
"start_typing_item_name" => "Start typing Item Name or scan Barcode...",
"stock" => "Stock",
"stock_location" => "Stock Location",
"sub_total" => "Subtotal",
"successfully_deleted" => "You have successfully deleted",
"successfully_restored" => "You have successfully restored",
"successfully_suspended_sale" => "Sale suspend successful.",
"successfully_updated" => "Sale update successful.",
"suspend_sale" => "Suspend",
"suspended_doc_id" => "Document",
"suspended_sale_id" => "ID",
"suspended_sales" => "Suspended",
"table" => "Table",
"takings" => "Daily Sales",
"tax" => "Tax",
"tax_id" => "Tax Id",
"tax_invoice" => "Tax Invoice",
"tax_percent" => "Tax %",
"taxed_ind" => "T",
"total" => "Total",
"total_tax_exclusive" => "Tax excluded",
"transaction_failed" => "Sales Transaction failed.",
"unable_to_add_item" => "Item add to Sale failed",
"unsuccessfully_deleted" => "Sale(s) delete failed.",
"unsuccessfully_restored" => "Sale(s) restore failed.",
"unsuccessfully_suspended_sale" => "Sale suspend failed.",
"unsuccessfully_updated" => "Sale update failed.",
"unsuspend" => "Unsuspend",
"unsuspend_and_delete" => "Action",
"update" => "Update",
"upi" => "UPI",
"visa" => "",
"wallet" => "Wallet",
"wholesale" => "",
"work_order" => "Work Order",
"work_order_number" => "Work Order Number",
"work_order_number_duplicate" => "Work Order Number must be unique.",
"work_order_sent" => "Work Order sent to",
"work_order_unsent" => "Work Order failed to be sent to",
];

View File

@@ -118,38 +118,4 @@ class Email_lib
return '<img id="image" src="data:' . $mimeType . ';base64,' . $logo_data . '" alt="company_logo">';
}
/**
* Send email with multiple attachments
*
* @param string $to
* @param string $subject
* @param string $message
* @param array $attachments
* @return bool
*/
public function sendMultipleAttachments(string $to, string $subject, string $message, array $attachments): bool
{
$email = $this->email;
$email->setFrom($this->config['email'], $this->config['company']);
$email->setTo($to);
$email->setSubject($subject);
$email->setMessage($message);
foreach ($attachments as $attachment) {
if (!empty($attachment) && file_exists($attachment)) {
$email->attach($attachment);
}
}
$result = $email->send();
if (!$result) {
log_message('error', $email->printDebugger());
}
return $result;
}
}

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Libraries\InvoiceAttachment;
interface InvoiceAttachment
{
/**
* Generate the attachment content and write to a temp file.
*
* @param array $saleData The sale data from _load_sale_data()
* @param string $type The document type (invoice, tax_invoice, quote, work_order, receipt)
* @return string|null Absolute path to generated file, or null on failure
*/
public function generate(array $saleData, string $type): ?string;
/**
* Check if this attachment type is applicable for the document type.
* E.g., UBL only works for invoice/tax_invoice
*
* @param string $type The document type
* @param array $saleData The sale data (to check invoice_number existence)
* @return bool
*/
public function isApplicableForType(string $type, array $saleData): bool;
/**
* Get the file extension for this attachment.
*
* @return string E.g., 'pdf', 'xml'
*/
public function getFileExtension(): string;
/**
* Get the config values that enable this attachment.
* Returns array of config values that should generate this attachment.
*
* @return array E.g., ['pdf_only', 'both'] for PDF
*/
public function getEnabledConfigValues(): array;
}

View File

@@ -1,75 +0,0 @@
<?php
namespace App\Libraries\InvoiceAttachment;
class InvoiceAttachmentGenerator
{
/** @var InvoiceAttachment[] */
private array $attachments = [];
/**
* Register an attachment generator.
*/
public function register(InvoiceAttachment $attachment): self
{
$this->attachments[] = $attachment;
return $this;
}
/**
* Create generator with attachments based on config.
* Factory method that instantiates the right attachments.
*
* @param string $invoiceFormat Config value: 'pdf_only', 'ubl_only', or 'both'
* @return self
*/
public static function createFromConfig(string $invoiceFormat): self
{
$generator = new self();
if (in_array($invoiceFormat, ['pdf_only', 'both'], true)) {
$generator->register(new PdfAttachment());
}
if (in_array($invoiceFormat, ['ubl_only', 'both'], true)) {
$generator->register(new UblAttachment());
}
return $generator;
}
/**
* Generate all applicable attachments for a sale.
*
* @param array $saleData The sale data
* @param string $type The document type
* @return string[] Array of file paths to generated attachments
*/
public function generateAttachments(array $saleData, string $type): array
{
$files = [];
foreach ($this->attachments as $attachment) {
if ($attachment->isApplicableForType($type, $saleData)) {
$filepath = $attachment->generate($saleData, $type);
if ($filepath !== null) {
$files[] = $filepath;
}
}
}
return $files;
}
/**
* Clean up temporary attachment files.
*
* @param string[] $files
*/
public static function cleanup(array $files): void
{
foreach ($files as $file) {
@unlink($file);
}
}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace App\Libraries\InvoiceAttachment;
use CodeIgniter\Config\Services;
class PdfAttachment implements InvoiceAttachment
{
/**
* @inheritDoc
*/
public function generate(array $saleData, string $type): ?string
{
$view = Services::renderer();
$html = $view->setData($saleData)->render("sales/{$type}_email");
helper(['dompdf', 'file']);
$tempPath = tempnam(sys_get_temp_dir(), 'ospos_pdf_');
if ($tempPath === false) {
log_message('error', 'PDF attachment: failed to create temp file');
return null;
}
$filename = $tempPath . '.pdf';
rename($tempPath, $filename);
$pdfContent = create_pdf($html);
if (file_put_contents($filename, $pdfContent) === false) {
log_message('error', 'PDF attachment: failed to write content');
@unlink($filename);
return null;
}
return $filename;
}
/**
* @inheritDoc
*/
public function isApplicableForType(string $type, array $saleData): bool
{
return true;
}
/**
* @inheritDoc
*/
public function getFileExtension(): string
{
return 'pdf';
}
/**
* @inheritDoc
*/
public function getEnabledConfigValues(): array
{
return ['pdf_only', 'both'];
}
}

View File

@@ -1,69 +0,0 @@
<?php
namespace App\Libraries\InvoiceAttachment;
use App\Libraries\UBLGenerator;
class UblAttachment implements InvoiceAttachment
{
public function __construct()
{
require_once ROOTPATH . 'vendor/autoload.php';
}
/**
* @inheritDoc
*/
public function generate(array $saleData, string $type): ?string
{
try {
$generator = new UBLGenerator();
$xml = $generator->generateUblInvoice($saleData);
$tempPath = tempnam(sys_get_temp_dir(), 'ospos_ubl_');
if ($tempPath === false) {
log_message('error', 'UBL attachment: failed to create temp file');
return null;
}
$filename = $tempPath . '.xml';
rename($tempPath, $filename);
if (file_put_contents($filename, $xml) === false) {
log_message('error', 'UBL attachment: failed to write content');
@unlink($filename);
return null;
}
return $filename;
} catch (\Exception $e) {
log_message('error', 'UBL attachment generation failed: ' . $e->getMessage());
return null;
}
}
/**
* @inheritDoc
*/
public function isApplicableForType(string $type, array $saleData): bool
{
return in_array($type, ['invoice', 'tax_invoice'], true)
&& !empty($saleData['invoice_number']);
}
/**
* @inheritDoc
*/
public function getFileExtension(): string
{
return 'xml';
}
/**
* @inheritDoc
*/
public function getEnabledConfigValues(): array
{
return ['ubl_only', 'both'];
}
}

View File

@@ -1,369 +0,0 @@
<?php
namespace App\Libraries;
use DateTime;
use NumNum\UBL\AccountingParty;
use NumNum\UBL\Address;
use NumNum\UBL\AllowanceCharge;
use NumNum\UBL\Contact;
use NumNum\UBL\Country;
use NumNum\UBL\Generator;
use NumNum\UBL\Invoice;
use NumNum\UBL\InvoiceLine;
use NumNum\UBL\Item;
use NumNum\UBL\LegalMonetaryTotal;
use NumNum\UBL\Party;
use NumNum\UBL\PartyTaxScheme;
use NumNum\UBL\Price;
use NumNum\UBL\TaxCategory;
use NumNum\UBL\TaxScheme;
use NumNum\UBL\TaxSubTotal;
use NumNum\UBL\TaxTotal;
use NumNum\UBL\UnitCode;
helper(['country']);
class UBLGenerator
{
/**
* Generate UBL invoice XML from sale data
*
* @param array $saleData Sale data from _load_sale_data()
*
* @return string UBL XML string
*/
public function generateUblInvoice(array $saleData): string
{
$taxScheme = (new TaxScheme())->setId('VAT');
$isTaxIncluded = ! empty($saleData['tax_included']);
$supplierParty = $this->buildSupplierParty($saleData, $taxScheme);
$customerParty = $this->buildCustomerParty($saleData['customer_object'] ?? null, $taxScheme);
$invoiceLines = $this->buildInvoiceLines($saleData, $taxScheme, $isTaxIncluded);
$taxTotal = $this->buildTaxTotal($saleData['taxes'] ?? [], $taxScheme);
$monetaryTotal = $this->buildMonetaryTotal($saleData);
$invoice = (new Invoice())
->setUBLVersionId('2.1')
->setCustomizationId('urn:cen.eu:en16931:2017')
->setProfileId('urn:fdc:peppol.eu:2017:poacc:billing:01:1.0')
->setId($saleData['invoice_number'] ?? '')
->setIssueDate(new DateTime($saleData['transaction_date'] ?? 'now'))
->setInvoiceTypeCode(380)
->setAccountingSupplierParty($supplierParty)
->setAccountingCustomerParty($customerParty)
->setInvoiceLines($invoiceLines)
->setTaxTotal($taxTotal)
->setLegalMonetaryTotal($monetaryTotal);
// Set currency if available
if (! empty($saleData['currency_code'])) {
Generator::$currencyID = $saleData['currency_code'];
}
$generator = new Generator();
return $generator->invoice($invoice);
}
/**
* Build supplier (seller) party
*/
protected function buildSupplierParty(array $saleData, TaxScheme $taxScheme): AccountingParty
{
$config = $saleData['config'] ?? [];
$addressParts = $this->parseAddress($config['address'] ?? '');
$countryCode = getCountryCode($config['country'] ?? '');
$country = (new Country())->setIdentificationCode($countryCode);
$address = (new Address())
->setStreetName($addressParts['street'] ?? '')
->setBuildingNumber($addressParts['number'] ?? '')
->setCityName($addressParts['city'] ?? '')
->setPostalZone($addressParts['zip'] ?? '')
->setCountrySubentity($config['state'] ?? '')
->setCountry($country);
$party = (new Party())
->setName($config['company'] ?? '')
->setPostalAddress($address);
$partyTaxScheme = null;
if (! empty($config['account_number'])) {
$partyTaxScheme = (new PartyTaxScheme())
->setCompanyId($config['account_number'])
->setTaxScheme($taxScheme);
$party->setPartyTaxScheme($partyTaxScheme);
} elseif (! empty($config['tax_id'])) {
// Use tax_id if account_number is not set
$partyTaxScheme = (new PartyTaxScheme())
->setCompanyId($config['tax_id'])
->setTaxScheme($taxScheme);
$party->setPartyTaxScheme($partyTaxScheme);
}
return (new AccountingParty())->setParty($party);
}
/**
* Build customer (buyer) party
*/
protected function buildCustomerParty(?object $customerInfo, TaxScheme $taxScheme): AccountingParty
{
if ($customerInfo === null) {
return (new AccountingParty())->setParty(new Party());
}
$countryCode = getCountryCode($customerInfo->country ?? '');
$country = (new Country())->setIdentificationCode($countryCode);
$address = (new Address())
->setStreetName($customerInfo->address_1 ?? '')
->setAddressLine([$customerInfo->address_2 ?? ''])
->setCityName($customerInfo->city ?? '')
->setPostalZone($customerInfo->zip ?? '')
->setCountrySubentity($customerInfo->state ?? '')
->setCountry($country);
$partyName = ! empty($customerInfo->company_name)
? $customerInfo->company_name
: trim(($customerInfo->first_name ?? '') . ' ' . ($customerInfo->last_name ?? ''));
$party = (new Party())
->setName($partyName)
->setPostalAddress($address);
if (! empty($customerInfo->email)) {
$contact = (new Contact())
->setElectronicMail($customerInfo->email)
->setTelephone($customerInfo->phone_number ?? '');
$party->setContact($contact);
}
$accountingParty = (new AccountingParty())->setParty($party);
if (! empty($customerInfo->account_number)) {
$accountingParty->setSupplierAssignedAccountId($customerInfo->account_number);
}
if (! empty($customerInfo->tax_id)) {
$partyTaxScheme = (new PartyTaxScheme())
->setCompanyId($customerInfo->tax_id)
->setTaxScheme($taxScheme);
$party->setPartyTaxScheme($partyTaxScheme);
}
return $accountingParty;
}
/**
* Build invoice lines
*/
protected function buildInvoiceLines(array $saleData, TaxScheme $taxScheme, bool $isTaxIncluded): array
{
$lines = [];
$itemTaxes = $saleData['item_taxes'] ?? [];
$cart = $saleData['cart'] ?? [];
foreach ($cart as $item) {
$itemId = $item['item_id'] ?? 0;
$quantity = (float) ($item['quantity'] ?? 0);
$unitPrice = (float) ($item['price'] ?? 0);
$discount = (float) ($item['discount'] ?? 0);
$discountType = (int) ($item['discount_type'] ?? 0);
// Calculate discount amount per unit
if ($discountType === PERCENT && $discount > 0) {
// Percentage discount
$discountAmountPerUnit = round($unitPrice * $discount / 100, 4);
} else {
// Fixed discount (discount is total for the line, divide by quantity)
$discountAmountPerUnit = $quantity > 0 ? round($discount / $quantity, 4) : 0;
}
// Net price per unit (after discount)
$netPricePerUnit = round($unitPrice - $discountAmountPerUnit, 4);
if ($netPricePerUnit < 0) {
$netPricePerUnit = 0;
}
// Get tax rate for this item
$taxRate = 0.0;
$taxCategory = (new TaxCategory())
->setId('S')
->setPercent(0)
->setTaxScheme($taxScheme);
if (isset($itemTaxes[$itemId]) && ! empty($itemTaxes[$itemId])) {
// Use the first (primary) tax for this item
$itemTax = $itemTaxes[$itemId][0];
$taxRate = (float) ($itemTax['percent'] ?? 0);
if (abs($taxRate) < 0.001) {
$taxCategory->setId('Z'); // Zero rated
} elseif ($taxRate < 0) {
$taxCategory->setId('E'); // Exempt
} else {
$taxCategory->setId('S'); // Standard
}
$taxCategory->setPercent(round($taxRate, 2));
}
// Calculate line extension amount (net line total)
$lineExtensionAmount = round($netPricePerUnit * $quantity, 2);
// Build Price - PriceAmount MUST be the net price excluding VAT per Peppol EN16931 (BR-27)
// "The price of an item, exclusive of VAT, after subtracting discount"
$price = (new Price())
->setBaseQuantity(1.0)
->setUnitCode(UnitCode::UNIT);
if ($isTaxIncluded && $taxRate > 0) {
// Tax-inclusive: cart price includes VAT, so extract the net price
// net_price = gross_price / (1 + tax_rate/100)
$taxExclusivePricePerUnit = round($netPricePerUnit / (1 + $taxRate / 100), 4);
$price->setPriceAmount($taxExclusivePricePerUnit);
// Recalculate line extension amount with tax-exclusive price
$lineExtensionAmount = round($taxExclusivePricePerUnit * $quantity, 2);
} else {
// Tax-exclusive: cart price is already the net price
$price->setPriceAmount(round($netPricePerUnit, 4));
}
// Add AllowanceCharge if there's a discount (gross-to-net price reduction)
if ($discountAmountPerUnit > 0) {
$allowanceCharge = (new AllowanceCharge())
->setChargeIndicator(false) // false = allowance/discount
->setAllowanceChargeReason('Discount')
->setAmount(round($discountAmountPerUnit, 4))
->setBaseAmount(round((float) ($item['price'] ?? 0), 4));
$price->setAllowanceCharge($allowanceCharge);
}
// Build Item
$itemObj = (new Item())
->setName($item['name'] ?? '')
->setDescription($item['description'] ?? '')
->setClassifiedTaxCategory($taxCategory);
// Add SellersItemIdentification if item_number exists (BR-25)
if (! empty($item['item_number'])) {
$itemObj->setSellersItemIdentification((string) $item['item_number']);
}
// Build InvoiceLine
$line = (new InvoiceLine())
->setId(isset($item['line']) ? (string) $item['line'] : '1')
->setInvoicedQuantity($quantity)
->setLineExtensionAmount($lineExtensionAmount)
->setItem($itemObj)
->setPrice($price);
$lines[] = $line;
}
return $lines;
}
/**
* Build tax total from sales_taxes table data
*/
protected function buildTaxTotal(array $taxes, TaxScheme $taxScheme): TaxTotal
{
$totalTax = '0';
$taxSubTotals = [];
foreach ($taxes as $tax) {
if (isset($tax['tax_rate'])) {
$taxRate = (string) $tax['tax_rate'];
$taxAmount = (string) ($tax['sale_tax_amount'] ?? '0');
// Use sale_tax_basis directly from DB instead of reverse-computing
$taxableAmount = (string) ($tax['sale_tax_basis'] ?? '0');
// Determine category ID based on tax rate
$categoryId = 'S'; // Standard
$floatRate = (float) $taxRate;
if (abs($floatRate) < 0.001) {
$categoryId = 'Z'; // Zero rated
} elseif ($floatRate < 0) {
$categoryId = 'E'; // Exempt
}
$taxCategory = (new TaxCategory())
->setId($categoryId)
->setPercent(round($floatRate, 2))
->setTaxScheme($taxScheme);
$taxSubTotal = (new TaxSubTotal())
->setTaxableAmount(round((float) $taxableAmount, 2))
->setTaxAmount(round((float) $taxAmount, 2))
->setTaxCategory($taxCategory);
$taxSubTotals[] = $taxSubTotal;
$totalTax = bcadd($totalTax, $taxAmount);
}
}
$taxTotal = new TaxTotal();
$taxTotal->setTaxAmount(round((float) $totalTax, 2));
foreach ($taxSubTotals as $subTotal) {
$taxTotal->addTaxSubTotal($subTotal);
}
return $taxTotal;
}
/**
* Build monetary total
*/
protected function buildMonetaryTotal(array $saleData): LegalMonetaryTotal
{
// In OSPOS, after get_totals(): subtotal is ALWAYS tax-exclusive (net)
// total is ALWAYS tax-inclusive (gross)
$subtotal = (float) ($saleData['subtotal'] ?? 0);
$total = (float) ($saleData['total'] ?? 0);
$amountDue = (float) ($saleData['amount_due'] ?? 0);
return (new LegalMonetaryTotal())
->setLineExtensionAmount(round($subtotal, 2))
->setTaxExclusiveAmount(round($subtotal, 2))
->setTaxInclusiveAmount(round($total, 2))
->setPayableAmount(round($amountDue, 2));
}
/**
* Parse address string into components
*/
protected function parseAddress(string $address): array
{
$parts = array_filter(array_map('trim', explode("\n", $address)));
$result = [
'street' => '',
'number' => '',
'city' => '',
'zip' => '',
];
if (! empty($parts)) {
$result['street'] = $parts[0];
if (isset($parts[1])) {
// Match 4-5 digit postal codes (e.g., 1234, 12345) followed by city name
if (preg_match('/(\d{4,5})\s*(.+)/', $parts[1], $matches)) {
$result['zip'] = $matches[1];
$result['city'] = $matches[2];
} else {
$result['city'] = $parts[1];
}
}
}
return $result;
}
}

View File

@@ -2,10 +2,10 @@
namespace App\Models;
use App\Libraries\Sale_lib;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\ResultInterface;
use CodeIgniter\Model;
use App\Libraries\Sale_lib;
use Config\OSPOS;
use ReflectionException;
@@ -14,11 +14,11 @@ use ReflectionException;
*/
class Sale extends Model
{
protected $table = 'sales';
protected $primaryKey = 'sale_id';
protected $table = 'sales';
protected $primaryKey = 'sale_id';
protected $useAutoIncrement = true;
protected $useSoftDeletes = false;
protected $allowedFields = [
protected $useSoftDeletes = false;
protected $allowedFields = [
'sale_time',
'customer_id',
'employee_id',
@@ -28,7 +28,7 @@ class Sale extends Model
'invoice_number',
'dinner_table_id',
'work_order_number',
'sale_type',
'sale_type'
];
public function __construct()
@@ -45,16 +45,16 @@ class Sale extends Model
$config = config(OSPOS::class)->settings;
$this->create_temp_table(['sale_id' => $sale_id]);
$decimals = totals_decimals();
$sales_tax = 'IFNULL(SUM(sales_items_taxes.sales_tax), 0)';
$decimals = totals_decimals();
$sales_tax = 'IFNULL(SUM(sales_items_taxes.sales_tax), 0)';
$cash_adjustment = 'IFNULL(SUM(payments.sale_cash_adjustment), 0)';
$sale_price = 'CASE WHEN sales_items.discount_type = ' . PERCENT
. " THEN sales_items.quantity_purchased * sales_items.item_unit_price - ROUND(sales_items.quantity_purchased * sales_items.item_unit_price * sales_items.discount / 100, {$decimals}) "
$sale_price = 'CASE WHEN sales_items.discount_type = ' . PERCENT
. " THEN sales_items.quantity_purchased * sales_items.item_unit_price - ROUND(sales_items.quantity_purchased * sales_items.item_unit_price * sales_items.discount / 100, $decimals) "
. 'ELSE sales_items.quantity_purchased * (sales_items.item_unit_price - sales_items.discount) END';
$sale_total = $config['tax_included']
? "ROUND(SUM({$sale_price}), {$decimals}) + {$cash_adjustment}"
: "ROUND(SUM({$sale_price}), {$decimals}) + {$sales_tax} + {$cash_adjustment}";
? "ROUND(SUM($sale_price), $decimals) + $cash_adjustment"
: "ROUND(SUM($sale_price), $decimals) + $sales_tax + $cash_adjustment";
$sql = 'sales.sale_id AS sale_id,
MAX(DATE(sales.sale_time)) AS sale_date,
@@ -73,9 +73,9 @@ class Sale extends Model
MAX(IFnull(payments.sale_cash_adjustment, 0)) AS cash_adjustment,
MAX(IFnull(payments.sale_cash_refund, 0)) AS cash_refund,
' . "
{$sale_total} AS amount_due,
$sale_total AS amount_due,
MAX(IFnull(payments.sale_payment_amount, 0)) AS amount_tendered,
(MAX(payments.sale_payment_amount)) - ({$sale_total}) AS change_due,
(MAX(payments.sale_payment_amount)) - ($sale_total) AS change_due,
" . '
MAX(payments.payment_type) AS payment_type';
@@ -89,7 +89,7 @@ class Sale extends Model
$builder->join(
'sales_items_taxes_temp AS sales_items_taxes',
'sales_items.sale_id = sales_items_taxes.sale_id AND sales_items.item_id = sales_items_taxes.item_id AND sales_items.line = sales_items_taxes.line',
'LEFT OUTER',
'LEFT OUTER'
);
$builder->where('sales.sale_id', $sale_id);
@@ -114,25 +114,15 @@ class Sale extends Model
public function search(?string $search, array $filters, ?int $rows = 0, ?int $limit_from = 0, ?string $sort = 'sales.sale_time', ?string $order = 'desc', ?bool $count_only = false)
{
// Set default values
if ($rows === null) {
$rows = 0;
}
if ($limit_from === null) {
$limit_from = 0;
}
if ($sort === null) {
$sort = 'sales.sale_time';
}
if ($order === null) {
$order = 'desc';
}
if ($count_only === null) {
$count_only = false;
}
if ($rows == null) $rows = 0;
if ($limit_from == null) $limit_from = 0;
if ($sort == null) $sort = 'sales.sale_time';
if ($order == null) $order = 'desc';
if ($count_only == null) $count_only = false;
$config = config(OSPOS::class)->settings;
$config = config(OSPOS::class)->settings;
$db_prefix = $this->db->getPrefix();
$decimals = totals_decimals();
$decimals = totals_decimals();
// Only non-suspended records
$where = 'sales.sale_status = 0 AND ';
@@ -143,18 +133,18 @@ class Sale extends Model
$this->create_temp_table_sales_payments_data($where);
$sale_price = 'CASE WHEN `sales_items`.`discount_type` = ' . PERCENT
. " THEN `sales_items`.`quantity_purchased` * `sales_items`.`item_unit_price` - ROUND(`sales_items`.`quantity_purchased` * `sales_items`.`item_unit_price` * `sales_items`.`discount` / 100, {$decimals}) "
. " THEN `sales_items`.`quantity_purchased` * `sales_items`.`item_unit_price` - ROUND(`sales_items`.`quantity_purchased` * `sales_items`.`item_unit_price` * `sales_items`.`discount` / 100, $decimals) "
. 'ELSE `sales_items`.`quantity_purchased` * (`sales_items`.`item_unit_price` - `sales_items`.`discount`) END';
$sale_cost = 'SUM(`sales_items`.`item_cost_price` * `sales_items`.`quantity_purchased`)';
$tax = 'IFNULL(SUM(`sales_items_taxes`.`tax`), 0)';
$sales_tax = 'IFNULL(SUM(`sales_items_taxes`.`sales_tax`), 0)';
$internal_tax = 'IFNULL(SUM(`sales_items_taxes`.`internal_tax`), 0)';
$tax = 'IFNULL(SUM(`sales_items_taxes`.`tax`), 0)';
$sales_tax = 'IFNULL(SUM(`sales_items_taxes`.`sales_tax`), 0)';
$internal_tax = 'IFNULL(SUM(`sales_items_taxes`.`internal_tax`), 0)';
$cash_adjustment = 'IFNULL(SUM(`payments`.`sale_cash_adjustment`), 0)';
$sale_subtotal = "ROUND(SUM({$sale_price}), {$decimals}) - {$internal_tax}";
$sale_total = "ROUND(SUM({$sale_price}), {$decimals}) + {$sales_tax} + {$cash_adjustment}";
$sale_subtotal = "ROUND(SUM($sale_price), $decimals) - $internal_tax";
$sale_total = "ROUND(SUM($sale_price), $decimals) + $sales_tax + $cash_adjustment";
$this->create_temp_table_sales_items_taxes_data($where);
@@ -181,7 +171,7 @@ class Sale extends Model
$sale_total . ' AS amount_due',
'MAX(`payments`.`sale_payment_amount`) AS amount_tendered',
'(MAX(`payments`.`sale_payment_amount`)) - (' . $sale_total . ') AS change_due',
'MAX(`payments`.`payment_type`) AS payment_type',
'MAX(`payments`.`payment_type`) AS payment_type'
], false);
}
@@ -192,7 +182,7 @@ class Sale extends Model
$builder->join(
'sales_items_taxes_temp AS sales_items_taxes',
'sales_items.sale_id = sales_items_taxes.sale_id AND sales_items.item_id = sales_items_taxes.item_id AND sales_items.line = sales_items_taxes.line',
'LEFT OUTER',
'LEFT OUTER'
);
$builder->where($where);
@@ -237,7 +227,7 @@ class Sale extends Model
$builder->where('sales.sale_time BETWEEN ' . $this->db->escape(rawurldecode($filters['start_date'])) . ' AND ' . $this->db->escape(rawurldecode($filters['end_date'])));
}
if (! empty($search)) { // TODO: duplicated code. We should think about refactoring out a method.
if (!empty($search)) { // TODO: duplicated code. We should think about refactoring out a method.
if ($filters['is_valid_receipt']) {
$pieces = explode(' ', $search);
$builder->where('sales.sale_id', $pieces[1]);
@@ -252,13 +242,13 @@ class Sale extends Model
}
// TODO: This needs to be converted to a switch statement
if ($filters['sale_type'] === 'sales') { // TODO: we need to think about refactoring this block to a switch statement.
if ($filters['sale_type'] == 'sales') { // TODO: we need to think about refactoring this block to a switch statement.
$builder->where('sales.sale_status = ' . COMPLETED . ' AND payment_amount > 0');
} elseif ($filters['sale_type'] === 'quotes') {
} elseif ($filters['sale_type'] == 'quotes') {
$builder->where('sales.sale_status = ' . SUSPENDED . ' AND sales.quote_number IS NOT NULL');
} elseif ($filters['sale_type'] === 'returns') {
} elseif ($filters['sale_type'] == 'returns') {
$builder->where('sales.sale_status = ' . COMPLETED . ' AND payment_amount < 0');
} elseif ($filters['sale_type'] === 'all') {
} elseif ($filters['sale_type'] == 'all') {
$builder->where('sales.sale_status = ' . COMPLETED);
}
@@ -300,12 +290,12 @@ class Sale extends Model
$payments = $builder->get()->getResultArray();
// Consider Gift Card as only one type of payment and do not show "Gift Card: 1, Gift Card: 2, etc." in the total
$gift_card_count = 0;
$gift_card_count = 0;
$gift_card_amount = 0;
foreach ($payments as $key => $payment) {
if (strstr($payment['payment_type'], lang('Sales.giftcard'))) {
$gift_card_count += $payment['count'];
$gift_card_count += $payment['count'];
$gift_card_amount += $payment['payment_amount'];
// Remove the "Gift Card: 1", "Gift Card: 2", etc. payment string
@@ -337,7 +327,7 @@ class Sale extends Model
{
$suggestions = [];
if (! $this->is_valid_receipt($search)) {
if (!$this->is_valid_receipt($search)) {
$builder = $this->db->table('sales');
$builder->distinct()->select('first_name, last_name');
$builder->join('people', 'people.person_id = sales.customer_id');
@@ -379,11 +369,21 @@ class Sale extends Model
return $builder->get();
}
/**
* @param string $year
* @param int $start_from
* @return int
*/
public function get_invoice_number_for_year(string $year = '', int $start_from = 0): int
{
return $this->get_number_for_year('invoice_number', $year, $start_from);
}
/**
* @param string $year
* @param int $start_from
* @return int
*/
public function get_quote_number_for_year(string $year = '', int $start_from = 0): int
{
return $this->get_number_for_year('quote_number', $year, $start_from);
@@ -394,32 +394,31 @@ class Sale extends Model
*/
private function get_number_for_year(string $field, string $year = '', int $start_from = 0): int
{
$year = $year === '' ? date('Y') : $year;
$year = $year == '' ? date('Y') : $year;
$builder = $this->db->table('sales');
$builder->select('COUNT( 1 ) AS number_year');
$builder->where('DATE_FORMAT(sale_time, "%Y" ) = ', $year);
$builder->where("{$field} IS NOT NULL");
$builder->where("$field IS NOT NULL");
$result = $builder->get()->getRowArray();
return $start_from + $result['number_year'];
return ($start_from + $result['number_year']);
}
/**
* Checks if valid receipt
*/
public function is_valid_receipt(?string &$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 is_valid_receipt(string|null &$receipt_sale_id): bool // TODO: like the others, maybe this should be an array rather than a delimited string... either that or the parameter name needs to be changed. $receipt_sale_id implies that it's an int.
{
$config = config(OSPOS::class)->settings;
if (! empty($receipt_sale_id)) {
if (!empty($receipt_sale_id)) {
// POS #
$pieces = explode(' ', $receipt_sale_id);
if (count($pieces) === 2 && preg_match('/(POS)/i', $pieces[0])) {
if (count($pieces) == 2 && preg_match('/(POS)/i', $pieces[0])) {
return $this->exists($pieces[1]);
}
if ($config['invoice_enable']) {
} elseif ($config['invoice_enable']) {
$sale_info = $this->get_sale_by_invoice_number($receipt_sale_id);
if ($sale_info->getNumRows() > 0) {
@@ -441,14 +440,11 @@ class Sale extends Model
$builder = $this->db->table('sales');
$builder->where('sale_id', $sale_id);
return $builder->get()->getNumRows() === 1; // TODO: ===
return ($builder->get()->getNumRows() == 1); // TODO: ===
}
/**
* Update sale
*
* @param mixed|null $sale_id
* @param mixed|null $sale_data
*/
public function update($sale_id = null, $sale_data = null): bool
{
@@ -459,7 +455,7 @@ class Sale extends Model
$success = $builder->update($update_data);
// Touch payment only if update sale is successful and there is a payments object otherwise the result would be to delete all the payments associated to the sale
if ($success && ! empty($sale_data['payments'])) {
if ($success && !empty($sale_data['payments'])) {
// Run these queries as a transaction, we want to make sure we do all or nothing
$this->db->transStart();
@@ -467,14 +463,14 @@ class Sale extends Model
// Add new payments
foreach ($sale_data['payments'] as $payment) {
$payment_id = $payment['payment_id'];
$payment_type = $payment['payment_type'];
$payment_amount = $payment['payment_amount'];
$cash_refund = $payment['cash_refund'];
$payment_id = $payment['payment_id'];
$payment_type = $payment['payment_type'];
$payment_amount = $payment['payment_amount'];
$cash_refund = $payment['cash_refund'];
$cash_adjustment = $payment['cash_adjustment'];
$employee_id = $payment['employee_id'];
$employee_id = $payment['employee_id'];
if ($payment_id === NEW_ENTRY && $payment_amount !== 0) {
if ($payment_id == NEW_ENTRY && $payment_amount != 0) {
// Add a new payment transaction
$sales_payments_data = [
'sale_id' => $sale_id,
@@ -482,17 +478,17 @@ class Sale extends Model
'payment_amount' => $payment_amount,
'cash_refund' => $cash_refund,
'cash_adjustment' => $cash_adjustment,
'employee_id' => $employee_id,
'employee_id' => $employee_id
];
$success = $builder->insert($sales_payments_data);
} elseif ($payment_id !== NEW_ENTRY) {
if ($payment_amount !== 0) {
} elseif ($payment_id != NEW_ENTRY) {
if ($payment_amount != 0) {
// Update existing payment transactions (payment_type only)
$sales_payments_data = [
'payment_type' => $payment_type,
'payment_amount' => $payment_amount,
'cash_refund' => $cash_refund,
'cash_adjustment' => $cash_adjustment,
'cash_adjustment' => $cash_adjustment
];
$builder->where('payment_id', $payment_id);
@@ -514,7 +510,6 @@ class Sale extends Model
/**
* Save the sale information after the sales is complete but before the final document is printed
* The sales_taxes variable needs to be initialized to an empty array before calling
*
* @throws ReflectionException
*/
public function save_value(
@@ -530,22 +525,22 @@ class Sale extends Model
int $sale_type,
?array $payments,
?int $dinner_table_id,
?array &$sales_taxes,
?array &$sales_taxes
): int { // TODO: this method returns the sale_id but the override is expecting it to return a bool. The signature needs to be reworked. Generally when there are more than 3 maybe 4 parameters, there's a good chance that an object needs to be passed rather than so many params.
$config = config(OSPOS::class)->settings;
$config = config(OSPOS::class)->settings;
$attribute = model(Attribute::class);
$customer = model(Customer::class);
$giftcard = model(Giftcard::class);
$customer = model(Customer::class);
$giftcard = model(Giftcard::class);
$inventory = model('Inventory');
$item = model(Item::class);
$item = model(Item::class);
$item_quantity = model(Item_quantity::class);
if ($sale_id !== NEW_ENTRY) {
if ($sale_id != NEW_ENTRY) {
$this->clear_suspended_sale_detail($sale_id);
}
if (count($items) === 0) { // TODO: ===
if (count($items) == 0) { // TODO: ===
return -1; // TODO: Replace -1 with a constant
}
@@ -559,13 +554,13 @@ class Sale extends Model
'quote_number' => $quote_number,
'work_order_number' => $work_order_number,
'dinner_table_id' => $dinner_table_id,
'sale_type' => $sale_type,
'sale_type' => $sale_type
];
// Run these queries as a transaction, we want to make sure we do all or nothing
$this->db->transStart();
if ($sale_id === NEW_ENTRY) {
if ($sale_id == NEW_ENTRY) {
$builder = $this->db->table('sales');
$builder->insert($sales_data);
$sale_id = $this->db->insertID();
@@ -575,19 +570,19 @@ class Sale extends Model
$builder->update($sales_data);
}
$total_amount = 0;
$total_amount = 0;
$total_amount_used = 0;
foreach ($payments as $payment_id => $payment) {
if (! empty(strstr($payment['payment_type'], lang('Sales.giftcard')))) {
if (!empty(strstr($payment['payment_type'], lang('Sales.giftcard')))) {
// We have a gift card, and we have to deduct the used value from the total value of the card.
$splitpayment = explode(':', $payment['payment_type']); // TODO: this variable doesn't follow our naming conventions. Probably should be refactored to split_payment.
$splitpayment = explode(':', $payment['payment_type']); // TODO: this variable doesn't follow our naming conventions. Probably should be refactored to split_payment.
$cur_giftcard_value = $giftcard->get_giftcard_value($splitpayment[1]); // TODO: this should be refactored to $current_giftcard_value
$giftcard->update_giftcard_value($splitpayment[1], $cur_giftcard_value - $payment['payment_amount']);
} elseif (! empty(strstr($payment['payment_type'], lang('Sales.rewards')))) {
} elseif (!empty(strstr($payment['payment_type'], lang('Sales.rewards')))) {
$cur_rewards_value = $customer->get_info($customer_id)->points;
$customer->update_reward_points_value($customer_id, $cur_rewards_value - $payment['payment_amount']);
$total_amount_used = (float) $total_amount_used + (float) ($payment['payment_amount']);
$total_amount_used = floatval($total_amount_used) + floatval($payment['payment_amount']);
}
$sales_payments_data = [
@@ -596,13 +591,13 @@ class Sale extends Model
'payment_amount' => $payment['payment_amount'],
'cash_refund' => $payment['cash_refund'],
'cash_adjustment' => $payment['cash_adjustment'],
'employee_id' => $employee_id,
'employee_id' => $employee_id
];
$builder = $this->db->table('sales_payments');
$builder->insert($sales_payments_data);
$total_amount = (float) $total_amount + (float) ($payment['payment_amount']) - (float) ($payment['cash_refund']);
$total_amount = floatval($total_amount) + floatval($payment['payment_amount']) - floatval($payment['cash_refund']);
}
$this->save_customer_rewards($customer_id, $sale_id, $total_amount, $total_amount_used);
@@ -612,7 +607,7 @@ class Sale extends Model
foreach ($items as $line => $item_data) {
$cur_item_info = $item->get_info($item_data['item_id']);
if ($item_data['price'] === 0.00) {
if ($item_data['price'] == 0.00) {
$item_data['discount'] = 0.00;
}
@@ -628,13 +623,13 @@ class Sale extends Model
'item_cost_price' => $item_data['cost_price'],
'item_unit_price' => $item_data['price'],
'item_location' => $item_data['item_location'],
'print_option' => $item_data['print_option'],
'print_option' => $item_data['print_option']
];
$builder = $this->db->table('sales_items');
$builder->insert($sales_items_data);
if ($cur_item_info->stock_type === HAS_STOCK && $sale_status === COMPLETED) { // TODO: === ?
if ($cur_item_info->stock_type == HAS_STOCK && $sale_status == COMPLETED) { // TODO: === ?
// Update stock quantity if item type is a standard stock item and the sale is a standard sale
$item_quantity_data = $item_quantity->get_item_quantity($item_data['item_id'], $item_data['item_location']);
@@ -642,10 +637,10 @@ class Sale extends Model
[
'quantity' => $item_quantity_data->quantity - $item_data['quantity'],
'item_id' => $item_data['item_id'],
'location_id' => $item_data['item_location'],
'location_id' => $item_data['item_location']
],
$item_data['item_id'],
$item_data['item_location'],
$item_data['item_location']
);
// If an items was deleted but later returned it's restored with this rule
@@ -655,13 +650,13 @@ class Sale extends Model
// Inventory Count Details
$sale_remarks = 'POS ' . $sale_id; // TODO: Use string interpolation here.
$inv_data = [
$inv_data = [
'trans_date' => date('Y-m-d H:i:s'),
'trans_items' => $item_data['item_id'],
'trans_user' => $employee_id,
'trans_location' => $item_data['item_location'],
'trans_comment' => $sale_remarks,
'trans_inventory' => -$item_data['quantity'],
'trans_inventory' => -$item_data['quantity']
];
$inventory->insert($inv_data, false);
@@ -670,14 +665,14 @@ class Sale extends Model
$attribute->copy_attribute_links($item_data['item_id'], 'sale_id', $sale_id);
}
if ($customer_id === NEW_ENTRY || $customer->taxable) {
if ($customer_id == NEW_ENTRY || $customer->taxable) {
$this->save_sales_tax($sale_id, $sales_taxes[0]);
$this->save_sales_items_taxes($sale_id, $sales_taxes[1]);
}
if ($config['dinner_table_enable']) {
$dinner_table = model(Dinner_table::class);
if ($sale_status === COMPLETED) { // TODO: === ?
if ($sale_status == COMPLETED) { // TODO: === ?
$dinner_table->release($dinner_table_id);
} else {
$dinner_table->occupy($dinner_table_id);
@@ -726,7 +721,7 @@ class Sale extends Model
'item_tax_amount' => $tax_item['item_tax_amount'],
'sales_tax_code_id' => $tax_item['sales_tax_code_id'],
'tax_category_id' => $tax_item['tax_category_id'],
'jurisdiction_id' => $tax_item['jurisdiction_id'],
'jurisdiction_id' => $tax_item['jurisdiction_id']
];
$builder->insert($sales_items_taxes);
@@ -761,36 +756,8 @@ class Sale extends Model
return $builder->get()->getResultArray();
}
/**
* Return all item taxes for a sale (for UBL invoice generation)
* Returns array keyed by item_id, each containing array of tax info
*/
public function get_sale_item_taxes_by_sale(int $sale_id): array
{
$builder = $this->db->table('sales_items_taxes');
$builder->select('item_id, line, name, percent, tax_type, item_tax_amount');
$builder->where('sale_id', $sale_id);
$builder->orderBy('line', 'asc');
$results = $builder->get()->getResultArray();
// Group by item_id
$itemTaxes = [];
foreach ($results as $row) {
$itemId = $row['item_id'];
if (! isset($itemTaxes[$itemId])) {
$itemTaxes[$itemId] = [];
}
$itemTaxes[$itemId][] = $row;
}
return $itemTaxes;
}
/**
* Deletes list of sales
*
* @throws ReflectionException
*/
public function delete_list(array $sale_ids, int $employee_id, bool $update_inventory = true): bool
@@ -820,10 +787,6 @@ class Sale extends Model
* Delete sale. Hard deletes are not supported for sales transactions.
* When a sale is "deleted" it is simply changed to a status of canceled.
* However, if applicable the inventory still needs to be updated
*
* @param mixed|null $sale_id
* @param mixed|null $employee_id
*
* @throws ReflectionException
*/
public function delete($sale_id = null, bool $purge = false, bool $update_inventory = true, $employee_id = null): bool
@@ -833,11 +796,11 @@ class Sale extends Model
$sale_status = $this->get_sale_status($sale_id);
if ($update_inventory && $sale_status === COMPLETED) {
if ($update_inventory && $sale_status == COMPLETED) {
// Defect, not all item deletions will be undone?
// Get array with all the items involved in the sale to update the inventory tracking
$inventory = model('Inventory');
$item = model(Item::class);
$inventory = model('Inventory');
$item = model(Item::class);
$item_quantity = model(Item_quantity::class);
$items = $this->get_sale_items($sale_id)->getResultArray();
@@ -845,7 +808,7 @@ class Sale extends Model
foreach ($items as $item_data) {
$cur_item_info = $item->get_info($item_data['item_id']);
if ($cur_item_info->stock_type === HAS_STOCK) {
if ($cur_item_info->stock_type == HAS_STOCK) {
// Create query to update inventory tracking
$inv_data = [
'trans_date' => date('Y-m-d H:i:s'),
@@ -853,7 +816,7 @@ class Sale extends Model
'trans_user' => $employee_id,
'trans_comment' => 'Deleting sale ' . $sale_id,
'trans_location' => $item_data['item_location'],
'trans_inventory' => $item_data['quantity_purchased'],
'trans_inventory' => $item_data['quantity_purchased']
];
// Update inventory
$inventory->insert($inv_data, false);
@@ -889,7 +852,7 @@ class Sale extends Model
public function get_sale_items_ordered(int $sale_id): ResultInterface
{
$config = config(OSPOS::class)->settings;
$item = model(Item::class);
$item = model(Item::class);
$builder = $this->db->table('sales_items AS sales_items');
$builder->select('
@@ -913,18 +876,18 @@ class Sale extends Model
$builder->where('sales_items.sale_id', $sale_id);
// Entry sequence (this will render kits in the expected sequence)
if ($config['line_sequence'] === '0') { // TODO: Replace these with constants and this should be converted to a switch.
if ($config['line_sequence'] == '0') { // TODO: Replace these with constants and this should be converted to a switch.
$builder->orderBy('line', 'asc');
}
// Group by Stock Type (nonstock first - type 1, stock next - type 0)
elseif ($config['line_sequence'] === '1') {
elseif ($config['line_sequence'] == '1') {
$builder->orderBy('stock_type', 'desc');
$builder->orderBy('sales_items.description', 'asc');
$builder->orderBy('items.name', 'asc');
$builder->orderBy('items.qty_per_pack', 'asc');
}
// Group by Item Category
elseif ($config['line_sequence'] === '2') {
elseif ($config['line_sequence'] == '2') {
$builder->orderBy('category', 'asc');
$builder->orderBy('sales_items.description', 'asc');
$builder->orderBy('items.name', 'asc');
@@ -964,8 +927,8 @@ class Sale extends Model
$payments[lang('Sales.rewards')] = lang('Sales.rewards');
}
$sale_lib = new Sale_lib();
if ($sale_lib->get_mode() === 'sale_work_order') {
$payments[lang('Sales.cash_deposit')] = lang('Sales.cash_deposit');
if ($sale_lib->get_mode() == 'sale_work_order') {
$payments[lang('Sales.cash_deposit')] = lang('Sales.cash_deposit');
$payments[lang('Sales.credit_deposit')] = lang('Sales.credit_deposit');
}
@@ -1006,11 +969,11 @@ class Sale extends Model
$builder = $this->db->table('sales');
$builder->where('quote_number', $quote_number);
if (! empty($sale_id)) {
if (!empty($sale_id)) {
$builder->where('sale_id !=', $sale_id);
}
return $builder->get()->getNumRows() === 1; // TODO: ===
return ($builder->get()->getNumRows() == 1); // TODO: ===
}
/**
@@ -1021,11 +984,11 @@ class Sale extends Model
$builder = $this->db->table('sales');
$builder->where('invoice_number', $invoice_number);
if (! empty($sale_id)) {
if (!empty($sale_id)) {
$builder->where('sale_id !=', $sale_id);
}
return $builder->get()->getNumRows() === 1; // TODO: ===
return ($builder->get()->getNumRows() == 1); // TODO: ===
}
/**
@@ -1035,11 +998,11 @@ class Sale extends Model
{
$builder = $this->db->table('sales');
$builder->where('invoice_number', $work_order_number);
if (! empty($sale_id)) {
if (!empty($sale_id)) {
$builder->where('sale_id !=', $sale_id);
}
return $builder->get()->getNumRows() === 1; // TODO: ===
return ($builder->get()->getNumRows() == 1); // TODO: ===
}
/**
@@ -1049,7 +1012,7 @@ class Sale extends Model
{
$giftcard = model(Giftcard::class);
if (! $giftcard->exists($giftcard->get_giftcard_id($giftcardNumber))) { // TODO: camelCase is used here for the variable name but we are using _ everywhere else. CI4 moved to camelCase... we should pick one and do that.
if (!$giftcard->exists($giftcard->get_giftcard_id($giftcardNumber))) { // TODO: camelCase is used here for the variable name but we are using _ everywhere else. CI4 moved to camelCase... we should pick one and do that.
return 0;
}
@@ -1080,22 +1043,22 @@ class Sale extends Model
$decimals = totals_decimals();
$sale_price = 'CASE WHEN sales_items.discount_type = ' . PERCENT
. " THEN sales_items.quantity_purchased * sales_items.item_unit_price - ROUND(sales_items.quantity_purchased * sales_items.item_unit_price * sales_items.discount / 100, {$decimals}) "
. " THEN sales_items.quantity_purchased * sales_items.item_unit_price - ROUND(sales_items.quantity_purchased * sales_items.item_unit_price * sales_items.discount / 100, $decimals) "
. 'ELSE sales_items.quantity_purchased * (sales_items.item_unit_price - sales_items.discount) END';
$sale_cost = 'SUM(sales_items.item_cost_price * sales_items.quantity_purchased)';
$tax = 'IFNULL(SUM(sales_items_taxes.tax), 0)';
$sales_tax = 'IFNULL(SUM(sales_items_taxes.sales_tax), 0)';
$internal_tax = 'IFNULL(SUM(sales_items_taxes.internal_tax), 0)';
$tax = 'IFNULL(SUM(sales_items_taxes.tax), 0)';
$sales_tax = 'IFNULL(SUM(sales_items_taxes.sales_tax), 0)';
$internal_tax = 'IFNULL(SUM(sales_items_taxes.internal_tax), 0)';
$cash_adjustment = 'IFNULL(SUM(payments.sale_cash_adjustment), 0)';
if ($config['tax_included']) {
$sale_total = "ROUND(SUM({$sale_price}), {$decimals}) + {$cash_adjustment}";
$sale_subtotal = "{$sale_total} - {$internal_tax}";
$sale_total = "ROUND(SUM($sale_price), $decimals) + $cash_adjustment";
$sale_subtotal = "$sale_total - $internal_tax";
} else {
$sale_subtotal = "ROUND(SUM({$sale_price}), {$decimals}) - {$internal_tax} + {$cash_adjustment}";
$sale_total = "ROUND(SUM({$sale_price}), {$decimals}) + {$sales_tax} + {$cash_adjustment}";
$sale_subtotal = "ROUND(SUM($sale_price), $decimals) - $internal_tax + $cash_adjustment";
$sale_total = "ROUND(SUM($sale_price), $decimals) + $sales_tax + $cash_adjustment";
}
// Create a temporary table to contain all the sum of taxes per sale item
@@ -1137,7 +1100,7 @@ class Sale extends Model
$this->db->query($sql);
$item = model(Item::class);
$sql = 'CREATE TEMPORARY TABLE IF NOT EXISTS ' . $this->db->prefixTable('sales_items_temp') .
$sql = 'CREATE TEMPORARY TABLE IF NOT EXISTS ' . $this->db->prefixTable('sales_items_temp') .
' (INDEX(sale_date), INDEX(sale_time), INDEX(sale_id))
(
SELECT
@@ -1159,7 +1122,7 @@ class Sale extends Model
MAX(sales.employee_id) AS employee_id,
MAX(CONCAT(employee.first_name, " ", employee.last_name)) AS employee_name,
items.item_id AS item_id,
MAX(' . $item->get_item_name() . ") AS name,
MAX(' . $item->get_item_name() . ') AS name,
MAX(items.item_number) AS item_number,
MAX(items.category) AS category,
MAX(items.supplier_id) AS supplier_id,
@@ -1174,12 +1137,12 @@ class Sale extends Model
MAX(sales_items.description) AS description,
MAX(payments.payment_type) AS payment_type,
MAX(payments.sale_payment_amount) AS sale_payment_amount,
{$sale_subtotal} AS subtotal,
{$tax} AS tax,
{$sale_total} AS total,
{$sale_cost} AS cost,
({$sale_subtotal} - {$sale_cost}) AS profit
' . "
$sale_subtotal AS subtotal,
$tax AS tax,
$sale_total AS total,
$sale_cost AS cost,
($sale_subtotal - $sale_cost) AS profit
" . '
FROM ' . $this->db->prefixTable('sales_items') . ' AS sales_items
INNER JOIN ' . $this->db->prefixTable('sales') . ' AS sales
@@ -1210,7 +1173,7 @@ class Sale extends Model
*/
public function get_all_suspended(?int $customer_id = null): array
{
if ($customer_id === NEW_ENTRY) {
if ($customer_id == NEW_ENTRY) {
$query = $this->db->query("SELECT sale_id, case when sale_type = '" . SALE_TYPE_QUOTE . "' THEN quote_number WHEN sale_type = '" . SALE_TYPE_WORK_ORDER . "' THEN work_order_number else sale_id end as doc_id, sale_id as suspended_sale_id, sale_status, sale_time, dinner_table_id, customer_id, employee_id, comment FROM "
. $this->db->prefixTable('sales') . ' where sale_status = ' . SUSPENDED);
} else {
@@ -1226,7 +1189,7 @@ class Sale extends Model
*/
public function get_dinner_table(int $sale_id) // TODO: this is returning null or the table_id. We can keep it this way but multiple return types can't be declared until PHP 8.x
{
if ($sale_id === NEW_ENTRY) {
if ($sale_id == NEW_ENTRY) {
return null;
}
@@ -1258,6 +1221,11 @@ class Sale extends Model
return $builder->get()->getRow()->sale_status;
}
/**
* @param int $sale_id
* @param int $sale_status
* @return void
*/
public function update_sale_status(int $sale_id, int $sale_status): void
{
$builder = $this->db->table('sales');
@@ -1276,7 +1244,7 @@ class Sale extends Model
$row = $builder->get()->getRow();
if ($row !== null) {
if ($row != null) {
return $row->quote_number;
}
@@ -1293,7 +1261,7 @@ class Sale extends Model
$row = $builder->get()->getRow();
if ($row !== null) { // TODO: === ?
if ($row != null) { // TODO: === ?
return $row->work_order_number;
}
@@ -1310,7 +1278,7 @@ class Sale extends Model
$row = $builder->get()->getRow();
if ($row !== null) { // TODO: === ?
if ($row != null) { // TODO: === ?
return $row->comment;
}
@@ -1340,7 +1308,7 @@ class Sale extends Model
$config = config(OSPOS::class)->settings;
if ($config['dinner_table_enable']) {
$dinner_table = model(Dinner_table::class);
$dinner_table = model(Dinner_table::class);
$dinner_table_id = $this->get_dinner_table($sale_id);
$dinner_table->release($dinner_table_id);
}
@@ -1362,7 +1330,7 @@ class Sale extends Model
$config = config(OSPOS::class)->settings;
if ($config['dinner_table_enable']) {
$dinner_table = model(Dinner_table::class);
$dinner_table = model(Dinner_table::class);
$dinner_table_id = $this->get_dinner_table($sale_id);
$dinner_table->release($dinner_table_id);
}
@@ -1397,24 +1365,30 @@ class Sale extends Model
return $builder->get();
}
/**
* @param int $customer_id
* @param int $sale_id
* @param float $total_amount
* @param float $total_amount_used
*/
private function save_customer_rewards(int $customer_id, int $sale_id, float $total_amount, float $total_amount_used): void
{
$config = config(OSPOS::class)->settings;
if (! empty($customer_id) && $config['customer_reward_enable']) {
$customer = model(Customer::class);
if (!empty($customer_id) && $config['customer_reward_enable']) {
$customer = model(Customer::class);
$customer_rewards = model(Customer_rewards::class);
$rewards = model(Rewards::class);
$rewards = model(Rewards::class);
$package_id = $customer->get_info($customer_id)->package_id;
if (! empty($package_id)) {
$points_percent = $customer_rewards->get_points_percent($package_id);
$points = $customer->get_info($customer_id)->points;
$points = ($points === null ? 0 : $points);
$points_percent = ($points_percent === null ? 0 : $points_percent);
if (!empty($package_id)) {
$points_percent = $customer_rewards->get_points_percent($package_id);
$points = $customer->get_info($customer_id)->points;
$points = ($points == null ? 0 : $points);
$points_percent = ($points_percent == null ? 0 : $points_percent);
$total_amount_earned = ($total_amount * $points_percent / 100);
$points += $total_amount_earned;
$points = $points + $total_amount_earned;
$customer->update_reward_points_value($customer_id, $points);
@@ -1428,6 +1402,7 @@ class Sale extends Model
/**
* Creates a temporary table to store the sales_payments data
*
* @param string $where
* @return array
*/
private function create_temp_table_sales_payments_data(string $where): void
@@ -1437,7 +1412,7 @@ class Sale extends Model
'payments.sale_id',
'SUM(CASE WHEN `payments`.`cash_adjustment` = 0 THEN `payments`.`payment_amount` ELSE 0 END) AS sale_payment_amount',
'SUM(CASE WHEN `payments`.`cash_adjustment` = 1 THEN `payments`.`payment_amount` ELSE 0 END) AS sale_cash_adjustment',
'GROUP_CONCAT(CONCAT(`payments`.`payment_type`, " ", (`payments`.`payment_amount` - `payments`.`cash_refund`)) SEPARATOR ", ") AS payment_type',
'GROUP_CONCAT(CONCAT(`payments`.`payment_type`, " ", (`payments`.`payment_amount` - `payments`.`cash_refund`)) SEPARATOR ", ") AS payment_type'
]);
$builder->join('sales', 'sales.sale_id = payments.sale_id', 'inner');
$builder->where($where);
@@ -1454,10 +1429,12 @@ class Sale extends Model
/**
* Temporary table to store the sales_items_taxes data
*
* @return BaseBuilder
* @param string $where
* @return \CodeIgniter\Database\BaseBuilder
*/
private function create_temp_table_sales_items_taxes_data(string $where): void
{
$builder = $this->db->table('sales_items_taxes AS sales_items_taxes');
$builder->select([
'sales_items_taxes.sale_id AS sale_id',
@@ -1465,7 +1442,7 @@ class Sale extends Model
'sales_items_taxes.line AS line',
'SUM(sales_items_taxes.item_tax_amount) AS tax',
'SUM(CASE WHEN sales_items_taxes.tax_type = 0 THEN sales_items_taxes.item_tax_amount ELSE 0 END) AS internal_tax',
'SUM(CASE WHEN sales_items_taxes.tax_type = 1 THEN sales_items_taxes.item_tax_amount ELSE 0 END) AS sales_tax',
'SUM(CASE WHEN sales_items_taxes.tax_type = 1 THEN sales_items_taxes.item_tax_amount ELSE 0 END) AS sales_tax'
]);
$builder->join('sales', 'sales.sale_id = sales_items_taxes.sale_id', 'inner');
$builder->join('sales_items', 'sales_items.sale_id = sales_items_taxes.sale_id AND sales_items.line = sales_items_taxes.line', 'inner');
@@ -1478,9 +1455,15 @@ class Sale extends Model
. ' (INDEX(sale_id), INDEX(item_id)) ENGINE=MEMORY AS (' . $sub_query . ')');
}
/**
* @param string $search
* @param array $filters
* @param BaseBuilder $builder
* @return void
*/
private function add_filters_to_query(?string $search, array $filters, BaseBuilder $builder): void
{
if (! empty($search)) { // TODO: this is duplicated code. We should think about refactoring out a method
if (!empty($search)) { // TODO: this is duplicated code. We should think about refactoring out a method
if ($filters['is_valid_receipt']) {
$pieces = explode(' ', $search);
$builder->where('sales.sale_id', $pieces[1]);
@@ -1498,15 +1481,16 @@ class Sale extends Model
}
}
if ($filters['location_id'] !== 'all') {
if ($filters['location_id'] != 'all') {
$builder->where('sales_items.item_location', $filters['location_id']);
}
if ($filters['selected_customer'] !== false) {
if ($filters['selected_customer'] != false) {
$sale_lib = new Sale_lib();
$builder->where('sales.customer_id', $sale_lib->get_customer());
}
if ($filters['only_invoices']) {
$builder->where('sales.invoice_number IS NOT NULL');
}

View File

@@ -69,7 +69,6 @@ if (isset($error_message)) {
<div class="btn btn-info btn-sm" id="show_email_button"><?= '<span class="glyphicon glyphicon-envelope">&nbsp;</span>' . lang('Sales.send_invoice') ?></div>
</a>
<?php endif; ?>
<?= anchor("sales/ublInvoice/$sale_id_num", '<span class="glyphicon glyphicon-download">&nbsp;</span>' . lang('Sales.download_ubl'), ['class' => 'btn btn-info btn-sm']) ?>
<?= anchor("sales", '<span class="glyphicon glyphicon-shopping-cart">&nbsp;</span>' . lang('Sales.register'), ['class' => 'btn btn-info btn-sm', 'id' => 'show_sales_button']) ?>
<?= anchor("sales/manage", '<span class="glyphicon glyphicon-list-alt">&nbsp;</span>' . lang('Sales.takings'), ['class' => 'btn btn-info btn-sm', 'id' => 'show_takings_button']) ?>
</div>

View File

@@ -31,8 +31,6 @@
"dompdf/dompdf": "^2.0.3",
"ezyang/htmlpurifier": "^4.17",
"laminas/laminas-escaper": "2.18.0",
"nesbot/carbon": "^2.72",
"num-num/ubl-invoice": "^2.4",
"paragonie/random_compat": "^2.0.21",
"picqer/php-barcode-generator": "^2.4.0",
"tamtamchik/namecase": "^3.0.0"

1763
composer.lock generated
View File

File diff suppressed because it is too large Load Diff

381
scripts/install-ubuntu.sh Normal file
View File

@@ -0,0 +1,381 @@
#!/bin/bash
set -e
COLOR_RED='\033[0;31m'
COLOR_GREEN='\033[0;32m'
COLOR_YELLOW='\033[1;33m'
COLOR_BLUE='\033[0;34m'
COLOR_RESET='\033[0m'
echo -e "${COLOR_BLUE}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ Open Source Point of Sale - Ubuntu Installer ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ Version 3.4+ ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
if [ "$EUID" -ne 0 ]; then
echo -e "${COLOR_RED}Please run this script as root or with sudo${COLOR_RESET}"
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
DB_HOST="${DB_HOST:-localhost}"
DB_NAME="${DB_NAME:-ospos}"
DB_USER="${DB_USER:-ospos}"
DB_PASS="${DB_PASS:-$(openssl rand -base64 24)}"
OSPOS_DIR="${OSPOS_DIR:-/var/www/ospos}"
OSPOS_VERSION="${OSPOS_VERSION:-}"
PHP_VERSION="${PHP_VERSION:-8.2}"
APACHE_SERVER_NAME="${APACHE_SERVER_NAME:-}"
SSL_EMAIL="${SSL_EMAIL:-}"
SSL_DOMAIN="${SSL_DOMAIN:-}"
MYSQL_ROOT_PASS="${MYSQL_ROOT_PASS:-}"
# Validate database variables contain only safe characters (alphanumeric, underscore, hyphen, dot)
validate_db_vars() {
local var_name="$1"
local var_value="$2"
local pattern='^[a-zA-Z0-9_\-\.]+$'
if [[ ! "$var_value" =~ $pattern ]]; then
echo -e "${COLOR_RED}Error: ${var_name} contains invalid characters. Only alphanumeric, underscore, hyphen, and dot are allowed.${COLOR_RESET}"
exit 1
fi
}
# Validate critical database variables
validate_db_vars "DB_NAME" "$DB_NAME"
validate_db_vars "DB_USER" "$DB_USER"
validate_db_vars "DB_HOST" "$DB_HOST"
# Check if running interactively
INTERACTIVE=false
if [ -t 0 ]; then
INTERACTIVE=true
fi
echo -e "${COLOR_YELLOW}Configuration:${COLOR_RESET}"
echo -e " Database Name: ${DB_NAME}"
echo -e " Database User: ${DB_USER}"
echo -e " Database Host: ${DB_HOST}"
echo -e " Install Directory: ${OSPOS_DIR}"
echo -e " PHP Version: ${PHP_VERSION}"
if [ -n "$OSPOS_VERSION" ]; then
echo -e " OSPOS Version: ${OSPOS_VERSION}"
else
echo -e " OSPOS Version: latest"
fi
if [ -n "$APACHE_SERVER_NAME" ]; then
echo -e " Server Name: ${APACHE_SERVER_NAME}"
fi
echo ""
if [ -d "$OSPOS_DIR" ]; then
echo -e "${COLOR_RED}Installation directory $OSPOS_DIR already exists${COLOR_RESET}"
echo -e "${COLOR_YELLOW}Remove it or set OSPOS_DIR environment variable${COLOR_RESET}"
exit 1
fi
echo -e "${COLOR_GREEN}[1/9] Updating system packages...${COLOR_RESET}"
apt-get update -qq
echo -e "${COLOR_GREEN}[2/9] Installing Apache, PHP, and dependencies...${COLOR_RESET}"
# Add PHP repository for newer PHP versions if not available in default repos
if ! apt-cache policy php${PHP_VERSION} 2>/dev/null | grep -q "Candidate:"; then
echo -e "${COLOR_YELLOW}PHP ${PHP_VERSION} not in default repos, adding ondrej/php PPA...${COLOR_RESET}"
apt-get install -y -qq software-properties-common
add-apt-repository -y ppa:ondrej/php
apt-get update -qq
fi
apt-get install -y -qq \
apache2 \
mariadb-server \
mariadb-client \
php${PHP_VERSION} \
php${PHP_VERSION}-mysql \
php${PHP_VERSION}-gd \
php${PHP_VERSION}-bcmath \
php${PHP_VERSION}-intl \
php${PHP_VERSION}-mbstring \
php${PHP_VERSION}-curl \
php${PHP_VERSION}-xml \
php${PHP_VERSION}-zip \
git \
curl \
unzip \
openssl
echo -e "${COLOR_GREEN}[3/9] Starting MariaDB...${COLOR_RESET}"
systemctl start mariadb
systemctl enable mariadb
if [ -z "$MYSQL_ROOT_PASS" ]; then
echo -e "${COLOR_BLUE}Securing MariaDB installation...${COLOR_RESET}"
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '';"
mysql -e "FLUSH PRIVILEGES;"
else
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${MYSQL_ROOT_PASS}';"
fi
echo -e "${COLOR_GREEN}[4/9] Creating database and user...${COLOR_RESET}"
if [ -n "$MYSQL_ROOT_PASS" ]; then
mysql -u root -p"${MYSQL_ROOT_PASS}" <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'${DB_HOST}' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'${DB_HOST}';
FLUSH PRIVILEGES;
EOF
else
mysql -u root <<EOF
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'${DB_HOST}' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'${DB_HOST}';
FLUSH PRIVILEGES;
EOF
fi
echo -e "${COLOR_GREEN}[5/9] Downloading OSPOS...${COLOR_RESET}"
mkdir -p "$(dirname "$OSPOS_DIR")"
cd "$(dirname "$OSPOS_DIR")"
if [ -z "$OSPOS_VERSION" ]; then
OSPOS_VERSION=$(curl -sS https://api.github.com/repos/opensourcepos/opensourcepos/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$OSPOS_VERSION" ]; then
echo -e "${COLOR_RED}Failed to get latest release version${COLOR_RESET}"
exit 1
fi
fi
echo -e "${COLOR_BLUE}Downloading OSPOS version ${OSPOS_VERSION}...${COLOR_RESET}"
ASSET_URL=$(curl -sS "https://api.github.com/repos/opensourcepos/opensourcepos/releases/tags/${OSPOS_VERSION}" | grep '"browser_download_url"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$ASSET_URL" ]; then
echo -e "${COLOR_RED}Failed to find release asset for ${OSPOS_VERSION}${COLOR_RESET}"
exit 1
fi
curl -sSL "$ASSET_URL" -o ospos.zip
if [ ! -f ospos.zip ] || [ ! -s ospos.zip ]; then
echo -e "${COLOR_RED}Failed to download OSPOS release ${OSPOS_VERSION}${COLOR_RESET}"
rm -f ospos.zip
exit 1
fi
unzip -q ospos.zip -d ospos-temp
mkdir -p "${OSPOS_DIR}"
cp -r ospos-temp/. "${OSPOS_DIR}/"
rm -rf ospos-temp ospos.zip
echo -e "${COLOR_GREEN}Downloaded OSPOS ${OSPOS_VERSION}${COLOR_RESET}"
echo -e "${COLOR_GREEN}[6/9] Setting up OSPOS...${COLOR_RESET}"
cd "${OSPOS_DIR}"
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 2>/dev/null
if [ -f "composer.json" ]; then
echo -e "${COLOR_BLUE}Installing dependencies...${COLOR_RESET}"
composer install --no-dev --optimize-autoloader --no-interaction --quiet 2>/dev/null
fi
echo -e "${COLOR_GREEN}[7/9] Configuring OSPOS...${COLOR_RESET}"
if [ -f ".env.example" ]; then
cp .env.example .env
fi
if [ -f ".env" ]; then
# Escape special characters in password for sed
ESCAPED_DB_PASS=$(printf '%s\n' "$DB_PASS" | sed 's/[&/\]/\\&/g')
sed -i "s|database\.default\.hostname = 'localhost'|database.default.hostname = '${DB_HOST}'|" .env
sed -i "s|database\.default\.database = 'ospos'|database.default.database = '${DB_NAME}'|" .env
sed -i "s|database\.default\.username = 'admin'|database.default.username = '${DB_USER}'|" .env
sed -i "s|database\.default\.password = 'pointofsale'|database.default.password = '${ESCAPED_DB_PASS}'|" .env
sed -i "s|CI_ENVIRONMENT = development|CI_ENVIRONMENT = production|" .env
if grep -q "encryption\.key = ''" .env; then
ENCRYPTION_KEY=$(openssl rand -base64 32)
ESCAPED_KEY=$(printf '%s\n' "$ENCRYPTION_KEY" | sed 's/[&/\]/\\&/g')
sed -i "s|encryption\.key = ''|encryption.key = '${ESCAPED_KEY}'|" .env
fi
fi
echo -e "${COLOR_GREEN}[8/9] Importing database schema...${COLOR_RESET}"
if [ -n "$MYSQL_ROOT_PASS" ]; then
mysql -u root -p"${MYSQL_ROOT_PASS}" ${DB_NAME} < app/Database/database.sql
else
mysql -u root ${DB_NAME} < app/Database/database.sql
fi
# Interactive SSL configuration
if $INTERACTIVE && [ -z "$SSL_EMAIL" ] && [ -z "$APACHE_SERVER_NAME" ]; then
echo ""
echo -e "${COLOR_BLUE}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_BLUE}║ SSL/TLS Configuration ║${COLOR_RESET}"
echo -e "${COLOR_BLUE}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
echo -e "${COLOR_YELLOW}SSL provides secure HTTPS access to your OSPOS installation.${COLOR_RESET}"
echo -e "${COLOR_YELLOW}For production, we recommend Let's Encrypt (free SSL certificate).${COLOR_RESET}"
echo ""
read -p "Configure SSL? (y/n) [n]: " CONFIGURE_SSL
CONFIGURE_SSL=${CONFIGURE_SSL:-n}
if [[ "$CONFIGURE_SSL" =~ ^[Yy]$ ]]; then
read -p "Enter your domain name (e.g., pos.example.com): " SSL_DOMAIN
SSL_DOMAIN=${SSL_DOMAIN:-localhost}
APACHE_SERVER_NAME=$SSL_DOMAIN
read -p "Enter your email for Let's Encrypt notifications: " SSL_EMAIL
if [ -z "$SSL_EMAIL" ]; then
echo -e "${COLOR_YELLOW}No email provided. Using self-signed certificate (not recommended for production).${COLOR_RESET}"
SSL_TYPE="self-signed"
else
SSL_TYPE="letsencrypt"
fi
else
APACHE_SERVER_NAME="localhost"
SSL_TYPE="none"
fi
fi
# Set default server name if not provided
if [ -z "$APACHE_SERVER_NAME" ]; then
APACHE_SERVER_NAME="localhost"
fi
# If SSL_EMAIL is set without SSL_DOMAIN, use APACHE_SERVER_NAME
if [ -n "$SSL_EMAIL" ] && [ -z "$SSL_DOMAIN" ] && [ "$APACHE_SERVER_NAME" != "localhost" ]; then
SSL_DOMAIN="$APACHE_SERVER_NAME"
fi
echo -e "${COLOR_GREEN}[9/9] Configuring Apache...${COLOR_RESET}"
cat > /etc/apache2/sites-available/ospos.conf <<EOF
<VirtualHost *:80>
ServerName ${APACHE_SERVER_NAME}
DocumentRoot ${OSPOS_DIR}/public
<Directory ${OSPOS_DIR}/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog \${APACHE_LOG_DIR}/ospos_error.log
CustomLog \${APACHE_LOG_DIR}/ospos_access.log combined
</VirtualHost>
EOF
a2enmod rewrite
a2dissite 000-default.conf
a2ensite ospos.conf
chown -R www-data:www-data "${OSPOS_DIR}"
chmod -R 750 "${OSPOS_DIR}/writable"
systemctl restart apache2
systemctl enable apache2
# Configure SSL if requested
if [ -n "$SSL_EMAIL" ] && [ -n "$SSL_DOMAIN" ]; then
# Let's Encrypt SSL
echo -e "${COLOR_BLUE}Installing Certbot for Let's Encrypt...${COLOR_RESET}"
apt-get install -y -qq certbot python3-certbot-apache
echo -e "${COLOR_BLUE}Obtaining SSL certificate for ${SSL_DOMAIN}...${COLOR_RESET}"
certbot --apache -d ${SSL_DOMAIN} --non-interactive --agree-tos --email ${SSL_EMAIL} --redirect
echo -e "${COLOR_BLUE}Setting up auto-renewal...${COLOR_RESET}"
systemctl enable certbot.timer
systemctl start certbot.timer
PROTOCOL="https"
FINAL_URL="https://${SSL_DOMAIN}/"
elif [ -n "$SSL_DOMAIN" ]; then
# Self-signed SSL
echo -e "${COLOR_BLUE}Generating self-signed SSL certificate...${COLOR_RESET}"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/ospos-selfsigned.key \
-out /etc/ssl/certs/ospos-selfsigned.crt \
-subj "/C=US/ST=State/L=City/O=Organization/CN=${SSL_DOMAIN}" 2>/dev/null
cat > /etc/apache2/sites-available/ospos-ssl.conf <<EOF
<VirtualHost *:443>
ServerName ${SSL_DOMAIN}
DocumentRoot ${OSPOS_DIR}/public
SSLEngine on
SSLCertificateFile /etc/ssl/certs/ospos-selfsigned.crt
SSLCertificateKeyFile /etc/ssl/private/ospos-selfsigned.key
<Directory ${OSPOS_DIR}/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog \${APACHE_LOG_DIR}/ospos_ssl_error.log
CustomLog \${APACHE_LOG_DIR}/ospos_ssl_access.log combined
</VirtualHost>
EOF
a2enmod ssl
a2ensite ospos-ssl.conf
cat > /etc/apache2/sites-available/ospos.conf <<EOF
<VirtualHost *:80>
ServerName ${SSL_DOMAIN}
Redirect permanent / https://${SSL_DOMAIN}/
</VirtualHost>
EOF
a2dissite ospos.conf
a2ensite ospos.conf
PROTOCOL="https"
FINAL_URL="https://${SSL_DOMAIN}/"
echo -e "${COLOR_YELLOW}Note: Your browser will show a security warning for self-signed${COLOR_RESET}"
echo -e "${COLOR_YELLOW} certificates. For production, re-run with an email for Let's Encrypt.${COLOR_RESET}"
else
PROTOCOL="http"
FINAL_URL="http://${APACHE_SERVER_NAME}/"
fi
systemctl restart apache2
# Configure allowed hostnames
if [ -f "${OSPOS_DIR}/.env" ]; then
sed -i "s|app\.allowedHostnames = ''|app.allowedHostnames = '${APACHE_SERVER_NAME}'|" ${OSPOS_DIR}/.env
fi
echo ""
echo -e "${COLOR_GREEN}╔══════════════════════════════════════════════════════════╗${COLOR_RESET}"
echo -e "${COLOR_GREEN}║ Installation Complete! ║${COLOR_RESET}"
echo -e "${COLOR_GREEN}╚══════════════════════════════════════════════════════════╝${COLOR_RESET}"
echo ""
echo -e "${COLOR_YELLOW}Database Credentials:${COLOR_RESET}"
echo -e " Database: ${DB_NAME}"
echo -e " Username: ${DB_USER}"
echo -e " Password: ${DB_PASS}"
echo ""
echo -e "${COLOR_YELLOW}Login Credentials:${COLOR_RESET}"
echo -e " URL: ${FINAL_URL}"
if [ -n "$SSL_EMAIL" ]; then
echo -e " SSL: Let's Encrypt (auto-renewal enabled)"
elif [ -n "$SSL_DOMAIN" ]; then
echo -e " SSL: Self-signed certificate"
else
echo -e " SSL: Not configured (HTTP only)"
fi
echo -e " Username: admin"
echo -e " Password: pointofsale"
echo ""
echo -e "${COLOR_RED}IMPORTANT: Change the default password after first login!${COLOR_RESET}"
echo ""
echo -e "${COLOR_BLUE}Configuration file: ${OSPOS_DIR}/.env${COLOR_RESET}"
echo ""

View File

@@ -1,121 +0,0 @@
<?php
namespace Tests\Libraries\InvoiceAttachment;
use CodeIgniter\Test\CIUnitTestCase;
use App\Libraries\InvoiceAttachment\InvoiceAttachmentGenerator;
use App\Libraries\InvoiceAttachment\InvoiceAttachment;
use App\Libraries\InvoiceAttachment\PdfAttachment;
use App\Libraries\InvoiceAttachment\UblAttachment;
class InvoiceAttachmentGeneratorTest extends CIUnitTestCase
{
public function testCreateFromConfigPdfOnly(): void
{
$generator = InvoiceAttachmentGenerator::createFromConfig('pdf_only');
$this->assertInstanceOf(InvoiceAttachmentGenerator::class, $generator);
}
public function testCreateFromConfigUblOnly(): void
{
$generator = InvoiceAttachmentGenerator::createFromConfig('ubl_only');
$this->assertInstanceOf(InvoiceAttachmentGenerator::class, $generator);
}
public function testCreateFromConfigBoth(): void
{
$generator = InvoiceAttachmentGenerator::createFromConfig('both');
$this->assertInstanceOf(InvoiceAttachmentGenerator::class, $generator);
}
public function testCreateFromConfigPdfOnlyRegistersPdfAttachment(): void
{
$generator = InvoiceAttachmentGenerator::createFromConfig('pdf_only');
$attachments = $this->getPrivateProperty($generator, 'attachments');
$this->assertCount(1, $attachments);
$this->assertInstanceOf(PdfAttachment::class, $attachments[0]);
}
public function testCreateFromConfigUblOnlyRegistersUblAttachment(): void
{
$generator = InvoiceAttachmentGenerator::createFromConfig('ubl_only');
$attachments = $this->getPrivateProperty($generator, 'attachments');
$this->assertCount(1, $attachments);
$this->assertInstanceOf(UblAttachment::class, $attachments[0]);
}
public function testCreateFromConfigBothRegistersBothAttachments(): void
{
$generator = InvoiceAttachmentGenerator::createFromConfig('both');
$attachments = $this->getPrivateProperty($generator, 'attachments');
$this->assertCount(2, $attachments);
$this->assertInstanceOf(PdfAttachment::class, $attachments[0]);
$this->assertInstanceOf(UblAttachment::class, $attachments[1]);
}
public function testRegisterAddsAttachment(): void
{
$generator = new InvoiceAttachmentGenerator();
$mockAttachment = new class implements InvoiceAttachment {
public function generate(array $saleData, string $type): ?string { return null; }
public function isApplicableForType(string $type, array $saleData): bool { return true; }
public function getFileExtension(): string { return 'test'; }
public function getEnabledConfigValues(): array { return ['test']; }
};
$result = $generator->register($mockAttachment);
$this->assertSame($generator, $result);
$attachments = $this->getPrivateProperty($generator, 'attachments');
$this->assertCount(1, $attachments);
}
public function testRegisterIsChainable(): void
{
$generator = new InvoiceAttachmentGenerator();
$mockAttachment = new class implements InvoiceAttachment {
public function generate(array $saleData, string $type): ?string { return null; }
public function isApplicableForType(string $type, array $saleData): bool { return true; }
public function getFileExtension(): string { return 'test'; }
public function getEnabledConfigValues(): array { return ['test']; }
};
$result = $generator->register($mockAttachment)->register($mockAttachment);
$attachments = $this->getPrivateProperty($result, 'attachments');
$this->assertCount(2, $attachments);
}
public function testGenerateAttachmentsReturnsEmptyArrayWhenNoAttachmentsRegistered(): void
{
$generator = new InvoiceAttachmentGenerator();
$result = $generator->generateAttachments([], 'invoice');
$this->assertIsArray($result);
$this->assertEmpty($result);
}
public function testCleanupRemovesFiles(): void
{
$tempFile1 = tempnam(sys_get_temp_dir(), 'test_');
$tempFile2 = tempnam(sys_get_temp_dir(), 'test_');
$this->assertFileExists($tempFile1);
$this->assertFileExists($tempFile2);
InvoiceAttachmentGenerator::cleanup([$tempFile1, $tempFile2]);
$this->assertFileDoesNotExist($tempFile1);
$this->assertFileDoesNotExist($tempFile2);
}
public function testCleanupHandlesNonExistentFiles(): void
{
// Should not throw an exception
InvoiceAttachmentGenerator::cleanup(['/non/existent/file1', '/non/existent/file2']);
$this->assertTrue(true);
}
}

View File

@@ -1,70 +0,0 @@
<?php
namespace Tests\Libraries\InvoiceAttachment;
use CodeIgniter\Test\CIUnitTestCase;
use App\Libraries\InvoiceAttachment\PdfAttachment;
class PdfAttachmentTest extends CIUnitTestCase
{
private PdfAttachment $attachment;
protected function setUp(): void
{
parent::setUp();
$this->attachment = new PdfAttachment();
}
public function testGetFileExtensionReturnsPdf(): void
{
$this->assertEquals('pdf', $this->attachment->getFileExtension());
}
public function testGetEnabledConfigValuesReturnsCorrectArray(): void
{
$values = $this->attachment->getEnabledConfigValues();
$this->assertIsArray($values);
$this->assertContains('pdf_only', $values);
$this->assertContains('both', $values);
$this->assertCount(2, $values);
}
public function testIsApplicableForTypeReturnsTrueForInvoice(): void
{
$this->assertTrue($this->attachment->isApplicableForType('invoice', []));
}
public function testIsApplicableForTypeReturnsTrueForTaxInvoice(): void
{
$this->assertTrue($this->attachment->isApplicableForType('tax_invoice', []));
}
public function testIsApplicableForTypeReturnsTrueForQuote(): void
{
$this->assertTrue($this->attachment->isApplicableForType('quote', []));
}
public function testIsApplicableForTypeReturnsTrueForWorkOrder(): void
{
$this->assertTrue($this->attachment->isApplicableForType('work_order', []));
}
public function testIsApplicableForTypeReturnsTrueForReceipt(): void
{
$this->assertTrue($this->attachment->isApplicableForType('receipt', []));
}
public function testIsApplicableForTypeReturnsTrueForAnyType(): void
{
// PDF should work for any document type
$this->assertTrue($this->attachment->isApplicableForType('random_type', []));
}
public function testIsApplicableForTypeIgnoresSaleData(): void
{
// PDF attachment doesn't depend on invoice_number
$this->assertTrue($this->attachment->isApplicableForType('invoice', ['invoice_number' => null]));
$this->assertTrue($this->attachment->isApplicableForType('invoice', ['invoice_number' => 'INV-001']));
}
}

View File

@@ -1,103 +0,0 @@
<?php
namespace Tests\Libraries\InvoiceAttachment;
use CodeIgniter\Test\CIUnitTestCase;
use App\Libraries\InvoiceAttachment\UblAttachment;
class UblAttachmentTest extends CIUnitTestCase
{
private UblAttachment $attachment;
protected function setUp(): void
{
parent::setUp();
$this->attachment = new UblAttachment();
}
public function testGetFileExtensionReturnsXml(): void
{
$this->assertEquals('xml', $this->attachment->getFileExtension());
}
public function testGetEnabledConfigValuesReturnsCorrectArray(): void
{
$values = $this->attachment->getEnabledConfigValues();
$this->assertIsArray($values);
$this->assertContains('ubl_only', $values);
$this->assertContains('both', $values);
$this->assertCount(2, $values);
}
public function testIsApplicableForTypeReturnsTrueForInvoiceWithInvoiceNumber(): void
{
$saleData = ['invoice_number' => 'INV-001'];
$this->assertTrue($this->attachment->isApplicableForType('invoice', $saleData));
}
public function testIsApplicableForTypeReturnsTrueForTaxInvoiceWithInvoiceNumber(): void
{
$saleData = ['invoice_number' => 'INV-001'];
$this->assertTrue($this->attachment->isApplicableForType('tax_invoice', $saleData));
}
public function testIsApplicableForTypeReturnsFalseForInvoiceWithoutInvoiceNumber(): void
{
$saleData = ['invoice_number' => null];
$this->assertFalse($this->attachment->isApplicableForType('invoice', $saleData));
}
public function testIsApplicableForTypeReturnsFalseForInvoiceWithEmptyInvoiceNumber(): void
{
$saleData = ['invoice_number' => ''];
$this->assertFalse($this->attachment->isApplicableForType('invoice', $saleData));
}
public function testIsApplicableForTypeReturnsFalseForInvoiceWithoutInvoiceNumberKey(): void
{
$saleData = [];
$this->assertFalse($this->attachment->isApplicableForType('invoice', $saleData));
}
public function testIsApplicableForTypeReturnsFalseForQuoteEvenWithInvoiceNumber(): void
{
$saleData = ['invoice_number' => 'INV-001'];
$this->assertFalse($this->attachment->isApplicableForType('quote', $saleData));
}
public function testIsApplicableForTypeReturnsFalseForWorkOrderEvenWithInvoiceNumber(): void
{
$saleData = ['invoice_number' => 'INV-001'];
$this->assertFalse($this->attachment->isApplicableForType('work_order', $saleData));
}
public function testIsApplicableForTypeReturnsFalseForReceiptEvenWithInvoiceNumber(): void
{
$saleData = ['invoice_number' => 'INV-001'];
$this->assertFalse($this->attachment->isApplicableForType('receipt', $saleData));
}
public function testIsApplicableForTypeReturnsFalseForUnknownType(): void
{
$saleData = ['invoice_number' => 'INV-001'];
$this->assertFalse($this->attachment->isApplicableForType('unknown_type', $saleData));
}
public function testGenerateReturnsNullForMissingConfig(): void
{
// Without proper sale_data, generate should fail gracefully
$result = $this->attachment->generate([], 'invoice');
$this->assertNull($result);
}
}