Compare commits

...

2 Commits

Author SHA1 Message Date
Ollama
4c55e7fcbd fix: Pin nesbot/carbon to ^2.72 for PHP 8.2 compatibility
The num-num/ubl-invoice package allows Carbon 2.x or 3.x, but Carbon 3.x
pulls in Symfony 8.x components that require PHP 8.4+. Pinning Carbon to
2.x ensures compatibility with OSPOS's PHP 8.2 requirement.
2026-05-18 15:28:03 +02:00
jekkos
4d9540633a Add Peppol (UBL) invoice support for Phase 1
Implementation of UBL 2.1 invoice generation to comply with Belgium's 2026 Peppol mandate.

Key changes:
- Add num-num/ubl-invoice dependency via composer.json
- Create Ubl_generator library to convert OSPOS sale data to UBL format
- Create country_helper.php to map country names to ISO 3166-1 alpha-2 codes
- Extend Email_lib to support multiple attachments for PDF+UBL emails
- Add getUblInvoice() method in Sales controller for UBL download
- Modify getSendPdf() to optionally attach UBL based on invoice_format config
- Add database migration for invoice_format configuration (pdf_only/ubl_only/both)
- Add UBL download button to invoice view
- Add UBL download link to sales manage table
- Add language keys for UBL-related UI elements

Data mapping:
- Company name/address -> Supplier Party
- account_number -> Company VAT number
- Customer address/country -> Customer Party with ISO country code
- Customer tax_id -> Customer VAT number
- Cart items -> InvoiceLines
- Taxes -> TaxCategory and TaxTotal
- Totals -> LegalMonetaryTotal

Features:
- Generate valid UBL 2.1 XML invoices
- Download UBL from invoice view and manage table
- Email with PDF, UBL, or both based on configuration
- Support for multiple customer countries with ISO code mapping
- Graceful handling of missing optional customer fields
2026-05-18 15:05:30 +02:00
18 changed files with 3503 additions and 1441 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
<?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

@@ -0,0 +1,229 @@
<?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,6 +85,7 @@ 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];
@@ -121,6 +122,13 @@ 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,234 +1,237 @@
<?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",
'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',
];

View File

@@ -118,4 +118,38 @@ 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

@@ -0,0 +1,40 @@
<?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

@@ -0,0 +1,75 @@
<?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

@@ -0,0 +1,61 @@
<?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

@@ -0,0 +1,69 @@
<?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

@@ -0,0 +1,369 @@
<?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,15 +114,25 @@ 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 ';
@@ -133,18 +143,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);
@@ -171,7 +181,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);
}
@@ -182,7 +192,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);
@@ -227,7 +237,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]);
@@ -242,13 +252,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);
}
@@ -290,12 +300,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
@@ -327,7 +337,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');
@@ -369,21 +379,11 @@ 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,31 +394,32 @@ 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|null &$receipt_sale_id): bool // TODO: like the others, maybe this should be an array rather than a delimited string... either that or the parameter name needs to be changed. $receipt_sale_id implies that it's an int.
public function 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.
{
$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]);
} elseif ($config['invoice_enable']) {
}
if ($config['invoice_enable']) {
$sale_info = $this->get_sale_by_invoice_number($receipt_sale_id);
if ($sale_info->getNumRows() > 0) {
@@ -440,11 +441,14 @@ 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
{
@@ -455,7 +459,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();
@@ -463,14 +467,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,
@@ -478,17 +482,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);
@@ -510,6 +514,7 @@ 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(
@@ -525,22 +530,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
}
@@ -554,13 +559,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();
@@ -570,19 +575,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 = floatval($total_amount_used) + floatval($payment['payment_amount']);
$total_amount_used = (float) $total_amount_used + (float) ($payment['payment_amount']);
}
$sales_payments_data = [
@@ -591,13 +596,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 = floatval($total_amount) + floatval($payment['payment_amount']) - floatval($payment['cash_refund']);
$total_amount = (float) $total_amount + (float) ($payment['payment_amount']) - (float) ($payment['cash_refund']);
}
$this->save_customer_rewards($customer_id, $sale_id, $total_amount, $total_amount_used);
@@ -607,7 +612,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;
}
@@ -623,13 +628,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']);
@@ -637,10 +642,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
@@ -650,13 +655,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);
@@ -665,14 +670,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);
@@ -721,7 +726,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);
@@ -756,8 +761,36 @@ 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
@@ -787,6 +820,10 @@ 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
@@ -796,11 +833,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();
@@ -808,7 +845,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'),
@@ -816,7 +853,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);
@@ -852,7 +889,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('
@@ -876,18 +913,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');
@@ -927,8 +964,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');
}
@@ -969,11 +1006,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: ===
}
/**
@@ -984,11 +1021,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: ===
}
/**
@@ -998,11 +1035,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: ===
}
/**
@@ -1012,7 +1049,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;
}
@@ -1043,22 +1080,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
@@ -1100,7 +1137,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
@@ -1122,7 +1159,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,
@@ -1137,12 +1174,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
@@ -1173,7 +1210,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 {
@@ -1189,7 +1226,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;
}
@@ -1221,11 +1258,6 @@ 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');
@@ -1244,7 +1276,7 @@ class Sale extends Model
$row = $builder->get()->getRow();
if ($row != null) {
if ($row !== null) {
return $row->quote_number;
}
@@ -1261,7 +1293,7 @@ class Sale extends Model
$row = $builder->get()->getRow();
if ($row != null) { // TODO: === ?
if ($row !== null) { // TODO: === ?
return $row->work_order_number;
}
@@ -1278,7 +1310,7 @@ class Sale extends Model
$row = $builder->get()->getRow();
if ($row != null) { // TODO: === ?
if ($row !== null) { // TODO: === ?
return $row->comment;
}
@@ -1308,7 +1340,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);
}
@@ -1330,7 +1362,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);
}
@@ -1365,30 +1397,24 @@ 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 = $points + $total_amount_earned;
$points += $total_amount_earned;
$customer->update_reward_points_value($customer_id, $points);
@@ -1402,7 +1428,6 @@ 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
@@ -1412,7 +1437,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);
@@ -1429,12 +1454,10 @@ class Sale extends Model
/**
* Temporary table to store the sales_items_taxes data
*
* @param string $where
* @return \CodeIgniter\Database\BaseBuilder
* @return 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',
@@ -1442,7 +1465,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');
@@ -1455,15 +1478,9 @@ 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]);
@@ -1481,16 +1498,15 @@ 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,6 +69,7 @@ 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,6 +31,8 @@
"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"

1755
composer.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
<?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

@@ -0,0 +1,70 @@
<?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

@@ -0,0 +1,103 @@
<?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);
}
}