From e6b288c291dea3cc197fdeb0e929c32c3a10a75a Mon Sep 17 00:00:00 2001 From: jekkos Date: Wed, 4 Mar 2026 19:52:20 +0000 Subject: [PATCH] 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 --- app/Controllers/Sales.php | 72 ++++- .../20260304000000_addUblConfig.php | 24 ++ app/Helpers/country_helper.php | 229 ++++++++++++++++ app/Helpers/tabular_helper.php | 8 + app/Language/ar-EG/Sales.php | 1 + app/Language/ar-LB/Sales.php | 1 + app/Language/az/Sales.php | 1 + app/Language/bg/Sales.php | 1 + app/Language/bs/Sales.php | 1 + app/Language/ckb/Sales.php | 1 + app/Language/cs/Sales.php | 1 + app/Language/da/Sales.php | 1 + app/Language/de-CH/Sales.php | 1 + app/Language/el/Sales.php | 1 + app/Language/en-GB/Sales.php | 1 + app/Language/en/Sales.php | 4 + app/Language/es-MX/Sales.php | 1 + app/Language/fa/Sales.php | 1 + app/Language/he/Sales.php | 1 + app/Language/hr-HR/Sales.php | 1 + app/Language/hu/Sales.php | 1 + app/Language/hy/Sales.php | 1 + app/Language/id/Sales.php | 1 + app/Language/lo/Sales.php | 1 + app/Language/nb/Sales.php | 1 + app/Language/nl-BE/Sales.php | 1 + app/Language/ro/Sales.php | 1 + app/Language/ru/Sales.php | 1 + app/Language/sv/Sales.php | 1 + app/Language/sw-KE/Sales.php | 1 + app/Language/sw-TZ/Sales.php | 1 + app/Language/th/Sales.php | 1 + app/Language/tl/Sales.php | 1 + app/Language/tr/Sales.php | 1 + app/Language/uk/Sales.php | 1 + app/Language/vi/Sales.php | 1 + app/Language/zh-Hans/Sales.php | 1 + app/Language/zh-Hant/Sales.php | 1 + app/Libraries/Email_lib.php | 33 +++ .../InvoiceAttachment/InvoiceAttachment.php | 40 +++ .../InvoiceAttachmentGenerator.php | 75 ++++++ .../InvoiceAttachment/PdfAttachment.php | 61 +++++ .../InvoiceAttachment/UblAttachment.php | 69 +++++ app/Libraries/UBLGenerator.php | 245 ++++++++++++++++++ app/Views/sales/invoice.php | 1 + composer.json | 1 + .../InvoiceAttachmentGeneratorTest.php | 121 +++++++++ .../InvoiceAttachment/PdfAttachmentTest.php | 70 +++++ .../InvoiceAttachment/UblAttachmentTest.php | 103 ++++++++ 49 files changed, 1181 insertions(+), 8 deletions(-) create mode 100644 app/Database/Migrations/20260304000000_addUblConfig.php create mode 100644 app/Helpers/country_helper.php create mode 100644 app/Libraries/InvoiceAttachment/InvoiceAttachment.php create mode 100644 app/Libraries/InvoiceAttachment/InvoiceAttachmentGenerator.php create mode 100644 app/Libraries/InvoiceAttachment/PdfAttachment.php create mode 100644 app/Libraries/InvoiceAttachment/UblAttachment.php create mode 100644 app/Libraries/UBLGenerator.php create mode 100644 tests/Libraries/InvoiceAttachment/InvoiceAttachmentGeneratorTest.php create mode 100644 tests/Libraries/InvoiceAttachment/PdfAttachmentTest.php create mode 100644 tests/Libraries/InvoiceAttachment/UblAttachmentTest.php diff --git a/app/Controllers/Sales.php b/app/Controllers/Sales.php index c618a82e0..c2d10d678 100644 --- a/app/Controllers/Sales.php +++ b/app/Controllers/Sales.php @@ -4,9 +4,11 @@ namespace App\Controllers; use App\Libraries\Barcode_lib; use App\Libraries\Email_lib; +use App\Libraries\InvoiceAttachment\InvoiceAttachmentGenerator; use App\Libraries\Sale_lib; use App\Libraries\Tax_lib; use App\Libraries\Token_lib; +use App\Libraries\UBLGenerator; use App\Models\Customer; use App\Models\Customer_rewards; use App\Models\Dinner_table; @@ -904,15 +906,28 @@ class Sales extends Secure_Controller $text = $this->token_lib->render($text, $tokens); $sale_data['mimetype'] = mime_content_type(FCPATH . 'uploads/' . $this->config['company_logo']); - // Generate email attachment: invoice in PDF format - $view = Services::renderer(); - $html = $view->setData($sale_data)->render("sales/$type" . '_email', $sale_data); + // Add config and customer object for attachment generation + $sale_data['config'] = $this->config; + $customer_id = $this->sale_lib->get_customer(); + if ($customer_id && $customer_id != NEW_ENTRY) { + $sale_data['customer_object'] = $this->customer->get_info($customer_id); + } - // Load PDF helper - helper(['dompdf', 'file']); - $filename = sys_get_temp_dir() . '/' . lang('Sales.' . $type) . '-' . str_replace('/', '-', $number) . '.pdf'; - if (file_put_contents($filename, create_pdf($html)) !== false) { - $result = $this->email_lib->sendEmail($to, $subject, $text, $filename); + // Generate attachments based on config + $invoiceFormat = $this->config['invoice_format'] ?? 'pdf_only'; + $attachmentGenerator = InvoiceAttachmentGenerator::createFromConfig($invoiceFormat); + $attachments = $attachmentGenerator->generateAttachments($sale_data, $type); + + if (!empty($attachments)) { + try { + if (count($attachments) === 1) { + $result = $this->email_lib->sendEmail($to, $subject, $text, $attachments[0]); + } else { + $result = $this->email_lib->sendMultipleAttachments($to, $subject, $text, $attachments); + } + } finally { + InvoiceAttachmentGenerator::cleanup($attachments); + } } $message = lang($result ? "Sales." . $type . "_sent" : "Sales." . $type . "_unsent") . ' ' . $to; @@ -1273,6 +1288,47 @@ class Sales extends Secure_Controller return view('sales/' . $data['invoice_view'], $data); } + /** + * Generate and download UBL invoice + * + * @param int $sale_id + * @return ResponseInterface + */ + public function getUBLInvoice(int $sale_id): ResponseInterface + { + $sale_info = $this->sale->get_info($sale_id)->getRowArray(); + + if (empty($sale_info) || empty($sale_info['invoice_number'])) { + return $this->response->setStatusCode(404)->setBody(lang('Sales.sale_not_found')); + } + + try { + $sale_data = $this->_load_sale_data($sale_id); + $sale_data['config'] = $this->config; + $customer_id = $this->sale_lib->get_customer(); + if ($customer_id && $customer_id != NEW_ENTRY) { + $sale_data['customer_object'] = $this->customer->get_info($customer_id); + } + $ublGenerator = new UBLGenerator(); + $xml = $ublGenerator->generateUblInvoice($sale_data); + + $rawFilename = lang('Sales.invoice') . '-' . str_replace('/', '-', $sale_data['invoice_number']) . '.xml'; + $rawFilename = str_replace(["\r", "\n"], '', $rawFilename); + $safeFilename = preg_replace('/[^A-Za-z0-9._-]/', '_', $rawFilename); + $contentDisposition = 'attachment; filename="' . $safeFilename . '"; filename*=UTF-8\'\'' . rawurlencode($rawFilename); + + return $this->response + ->setHeader('Content-Type', 'application/xml') + ->setHeader('Content-Disposition', $contentDisposition) + ->setBody($xml); + } catch (\Exception $e) { + log_message('error', 'UBL generation failed: ' . $e->getMessage()); + return $this->response->setStatusCode(500)->setBody(lang('Sales.ubl_generation_failed')); + } finally { + $this->sale_lib->clear_all(); + } + } + /** * Edits an existing sale or work order. Used in app/Views/sales/form.php * diff --git a/app/Database/Migrations/20260304000000_addUblConfig.php b/app/Database/Migrations/20260304000000_addUblConfig.php new file mode 100644 index 000000000..9165f673a --- /dev/null +++ b/app/Database/Migrations/20260304000000_addUblConfig.php @@ -0,0 +1,24 @@ + '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(); + } +} \ No newline at end of file diff --git a/app/Helpers/country_helper.php b/app/Helpers/country_helper.php new file mode 100644 index 000000000..b56f21042 --- /dev/null +++ b/app/Helpers/country_helper.php @@ -0,0 +1,229 @@ + '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 + } +} \ No newline at end of file diff --git a/app/Helpers/tabular_helper.php b/app/Helpers/tabular_helper.php index 70effeba7..10083b67c 100644 --- a/app/Helpers/tabular_helper.php +++ b/app/Helpers/tabular_helper.php @@ -84,6 +84,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]; @@ -120,6 +121,13 @@ function get_sale_data_row(object $sale): array '', ['title' => lang('Sales.show_invoice')] ); + $row['ubl'] = empty($sale->invoice_number) + ? '-' + : anchor( + "$controller/getUBLInvoice/$sale->sale_id", + '', + ['title' => lang('Sales.download_ubl'), 'target' => '_blank'] + ); } $row['receipt'] = anchor( diff --git a/app/Language/ar-EG/Sales.php b/app/Language/ar-EG/Sales.php index bf8d5b7cd..330c9ffb7 100644 --- a/app/Language/ar-EG/Sales.php +++ b/app/Language/ar-EG/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "رقم طلب العمل يجب ان يكون فريد.", "work_order_sent" => "تم ارسال طلب العمل الى", "work_order_unsent" => "فشل في ارسال طلب العمل الى", + "sale_not_found" => "البيع غير موجود", ]; diff --git a/app/Language/ar-LB/Sales.php b/app/Language/ar-LB/Sales.php index 112838301..bf36afea2 100644 --- a/app/Language/ar-LB/Sales.php +++ b/app/Language/ar-LB/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "رقم طلب العمل يجب ان يكون فريد.", "work_order_sent" => "تم ارسال طلب العمل الى", "work_order_unsent" => "فشل في ارسال طلب العمل الى", + "sale_not_found" => "البيع غير موجود", ]; diff --git a/app/Language/az/Sales.php b/app/Language/az/Sales.php index 4ab9586c9..95ece31ba 100644 --- a/app/Language/az/Sales.php +++ b/app/Language/az/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "İş sifarişinin nömrəsi unikal olmalıdır.", "work_order_sent" => "İş sifarişi göndərildi", "work_order_unsent" => "İş Sifarişi göndərilməmişdi", + "sale_not_found" => "Satış tapılmadı", ]; diff --git a/app/Language/bg/Sales.php b/app/Language/bg/Sales.php index 43293ef6f..35ec420be 100644 --- a/app/Language/bg/Sales.php +++ b/app/Language/bg/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "Номерът на работната поръчка трябва да е уникален.", "work_order_sent" => "Работната поръчка е изпратена до", "work_order_unsent" => "Работната поръчка не бе изпратена до", + "sale_not_found" => "Продажбата не е намерена", ]; diff --git a/app/Language/bs/Sales.php b/app/Language/bs/Sales.php index f623af079..a1b3ec84e 100644 --- a/app/Language/bs/Sales.php +++ b/app/Language/bs/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "Broj radnog naloga mora biti jedinstven.", "work_order_sent" => "Radni nalog poslat na", "work_order_unsent" => "Slanje radnog naloga nije uspjelo", + "sale_not_found" => "Prodaja nije pronađena", ]; diff --git a/app/Language/ckb/Sales.php b/app/Language/ckb/Sales.php index 7f5d46dc2..d7b93080d 100644 --- a/app/Language/ckb/Sales.php +++ b/app/Language/ckb/Sales.php @@ -223,4 +223,5 @@ return [ 'work_order_number_duplicate' => "ژمارەی داواکاری کار دەبێت تایبەت بێت.", 'work_order_sent' => "داواکاری کار نێردرا بۆ", 'work_order_unsent' => "داواکاری کار شکستی هێنا لە ناردنی بۆ", + 'sale_not_found' => "فرۆش نەدۆزرایەوە", ]; diff --git a/app/Language/cs/Sales.php b/app/Language/cs/Sales.php index 623446543..418d8d8ad 100644 --- a/app/Language/cs/Sales.php +++ b/app/Language/cs/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "", "work_order_sent" => "", "work_order_unsent" => "", + "sale_not_found" => "Prodej nenalezen", ]; diff --git a/app/Language/da/Sales.php b/app/Language/da/Sales.php index ee07a62eb..a4e50fcaf 100644 --- a/app/Language/da/Sales.php +++ b/app/Language/da/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "", "work_order_sent" => "", "work_order_unsent" => "", + "sale_not_found" => "Salg ikke fundet", ]; diff --git a/app/Language/de-CH/Sales.php b/app/Language/de-CH/Sales.php index 32d1cc228..018beb709 100644 --- a/app/Language/de-CH/Sales.php +++ b/app/Language/de-CH/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "", "work_order_sent" => "", "work_order_unsent" => "", + "sale_not_found" => "Verkauf nicht gefunden", ]; diff --git a/app/Language/el/Sales.php b/app/Language/el/Sales.php index 90dd0d5d0..d11b03c8f 100644 --- a/app/Language/el/Sales.php +++ b/app/Language/el/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "Ο Αριθμός της Παραγγελίας Εργασίας πρέπει να είναι μοναδικός.", "work_order_sent" => "Εντολή Εργασίας απεστάλη σε", "work_order_unsent" => "Ανεπιτυχής αποστολή Εντολής Εργασίας", + "sale_not_found" => "Η πώληση δεν βρέθηκε", ]; diff --git a/app/Language/en-GB/Sales.php b/app/Language/en-GB/Sales.php index d9678ba44..bb2cc7947 100644 --- a/app/Language/en-GB/Sales.php +++ b/app/Language/en-GB/Sales.php @@ -223,4 +223,5 @@ return [ "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", + "sale_not_found" => "Sale not found", ]; diff --git a/app/Language/en/Sales.php b/app/Language/en/Sales.php index 6c7d93d48..0249d656e 100644 --- a/app/Language/en/Sales.php +++ b/app/Language/en/Sales.php @@ -223,4 +223,8 @@ return [ "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", + "ubl_invoice" => "UBL Invoice", + "download_ubl" => "Download UBL Invoice", + "ubl_generation_failed" => "Failed to generate UBL invoice", + "sale_not_found" => "Sale not found", ]; diff --git a/app/Language/es-MX/Sales.php b/app/Language/es-MX/Sales.php index b0e521c5b..837ed3faa 100644 --- a/app/Language/es-MX/Sales.php +++ b/app/Language/es-MX/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "El número de orden de trabajo debe ser único.", "work_order_sent" => "Orden de trabajo enviada a", "work_order_unsent" => "Falló la Orden de Trabajo al enviar a", + "sale_not_found" => "Venta no encontrada", ]; diff --git a/app/Language/fa/Sales.php b/app/Language/fa/Sales.php index 182a534e2..2479b5eef 100644 --- a/app/Language/fa/Sales.php +++ b/app/Language/fa/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "شماره سفارش کار باید منحصر به فرد باشد.", "work_order_sent" => "دستور کار ارسال شده به", "work_order_unsent" => "دستور کار نتوانست به ارسال شود", + "sale_not_found" => "فروش یافت نشد", ]; diff --git a/app/Language/he/Sales.php b/app/Language/he/Sales.php index f2bd399ba..07d909464 100644 --- a/app/Language/he/Sales.php +++ b/app/Language/he/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "מספר הזמנת עבודה חייב להיות ייחודי.", "work_order_sent" => "הזמנת עבודה נשלחה אל", "work_order_unsent" => "הזמנת עבודה לא נשלחה אל", + "sale_not_found" => "המכירה לא נמצאה", ]; diff --git a/app/Language/hr-HR/Sales.php b/app/Language/hr-HR/Sales.php index 9e2df3b99..64222c590 100644 --- a/app/Language/hr-HR/Sales.php +++ b/app/Language/hr-HR/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "", "work_order_sent" => "", "work_order_unsent" => "", + "sale_not_found" => "Prodaja nije pronađena", ]; diff --git a/app/Language/hu/Sales.php b/app/Language/hu/Sales.php index 5928b2708..e60ea7ab9 100644 --- a/app/Language/hu/Sales.php +++ b/app/Language/hu/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "", "work_order_sent" => "", "work_order_unsent" => "", + "sale_not_found" => "Értékesítés nem található", ]; diff --git a/app/Language/hy/Sales.php b/app/Language/hy/Sales.php index b38f57446..183a36f3d 100644 --- a/app/Language/hy/Sales.php +++ b/app/Language/hy/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "", "work_order_sent" => "", "work_order_unsent" => "", + "sale_not_found" => "Վաճառքը գտնված չէ", ]; diff --git a/app/Language/id/Sales.php b/app/Language/id/Sales.php index e15191351..4cb36bdc8 100644 --- a/app/Language/id/Sales.php +++ b/app/Language/id/Sales.php @@ -222,5 +222,6 @@ return [ "work_order_number_duplicate" => "Nomor Perintah Kerja tidak boleh sama.", "work_order_sent" => "Perintah Kerja dikirim ke", "work_order_unsent" => "Perintah Kerja gagal dikirim ke", + "sale_not_found" => "Penjualan tidak ditemukan", "selected_customer" => "Pelanggan Terpilih", ]; diff --git a/app/Language/lo/Sales.php b/app/Language/lo/Sales.php index 66745059f..6d9a5f561 100644 --- a/app/Language/lo/Sales.php +++ b/app/Language/lo/Sales.php @@ -222,4 +222,5 @@ return [ "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", + "sale_not_found" => "Sale not found", ]; diff --git a/app/Language/nb/Sales.php b/app/Language/nb/Sales.php index b38f57446..17deba1a0 100644 --- a/app/Language/nb/Sales.php +++ b/app/Language/nb/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "", "work_order_sent" => "", "work_order_unsent" => "", + "sale_not_found" => "Salg ikke funnet", ]; diff --git a/app/Language/nl-BE/Sales.php b/app/Language/nl-BE/Sales.php index f2768e8ed..c002a2678 100644 --- a/app/Language/nl-BE/Sales.php +++ b/app/Language/nl-BE/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "Werkorder Nummer moet uniek zijn.", "work_order_sent" => "Werkorder verzonder naar", "work_order_unsent" => "Werkorder kon niet verzonden worden naar", + "sale_not_found" => "Verkoop niet gevonden", ]; diff --git a/app/Language/ro/Sales.php b/app/Language/ro/Sales.php index 815b1176d..711076cb6 100644 --- a/app/Language/ro/Sales.php +++ b/app/Language/ro/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "", "work_order_sent" => "", "work_order_unsent" => "", + "sale_not_found" => "Vânzarea nu a fost găsită", ]; diff --git a/app/Language/ru/Sales.php b/app/Language/ru/Sales.php index 7a3c2a977..f1bbcc947 100644 --- a/app/Language/ru/Sales.php +++ b/app/Language/ru/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "Номер рабочего заказа должен быть уникальным.", "work_order_sent" => "Рабочий заказ отправлен", "work_order_unsent" => "Не удалось отправить рабочий заказ", + "sale_not_found" => "Продажа не найдена", ]; diff --git a/app/Language/sv/Sales.php b/app/Language/sv/Sales.php index b492a389f..c7e30258e 100644 --- a/app/Language/sv/Sales.php +++ b/app/Language/sv/Sales.php @@ -222,5 +222,6 @@ return [ "work_order_number_duplicate" => "Arbetsorder nummer måste vara unikt.", "work_order_sent" => "Arbetsorder skickad till", "work_order_unsent" => "Arbetsorder gick ej att skicka till", + "sale_not_found" => "Försäljning hittades inte", "selected_customer" => "Vald kund", ]; diff --git a/app/Language/sw-KE/Sales.php b/app/Language/sw-KE/Sales.php index 2dc789bda..44ada4399 100644 --- a/app/Language/sw-KE/Sales.php +++ b/app/Language/sw-KE/Sales.php @@ -224,4 +224,5 @@ return [ "work_order_number_duplicate" => "Nambari ya Agizo la Kazi lazima iwe ya kipekee.", "work_order_sent" => "Agizo la Kazi limetumwa kwa", "work_order_unsent" => "Imeshindikana kutuma Agizo la Kazi kwa", + "sale_not_found" => "Mauzo hayapatikani", ]; \ No newline at end of file diff --git a/app/Language/sw-TZ/Sales.php b/app/Language/sw-TZ/Sales.php index 2dc789bda..44ada4399 100644 --- a/app/Language/sw-TZ/Sales.php +++ b/app/Language/sw-TZ/Sales.php @@ -224,4 +224,5 @@ return [ "work_order_number_duplicate" => "Nambari ya Agizo la Kazi lazima iwe ya kipekee.", "work_order_sent" => "Agizo la Kazi limetumwa kwa", "work_order_unsent" => "Imeshindikana kutuma Agizo la Kazi kwa", + "sale_not_found" => "Mauzo hayapatikani", ]; \ No newline at end of file diff --git a/app/Language/th/Sales.php b/app/Language/th/Sales.php index 0a8ec17b1..32dc0924a 100644 --- a/app/Language/th/Sales.php +++ b/app/Language/th/Sales.php @@ -222,5 +222,6 @@ return [ "work_order_number_duplicate" => "หมายเลขคำสั่งงานต้องไม่ซ้ำกัน", "work_order_sent" => "คำสั่งงานส่งถึง", "work_order_unsent" => "ส่งคำสั่งงานล้มเหลว", + "sale_not_found" => "ไม่พบการขาย", "selected_customer" => "ลูกค้าที่เลือก", ]; diff --git a/app/Language/tl/Sales.php b/app/Language/tl/Sales.php index 3cb4d1653..fc1b00dd6 100644 --- a/app/Language/tl/Sales.php +++ b/app/Language/tl/Sales.php @@ -222,4 +222,5 @@ return [ "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", + "sale_not_found" => "Sale not found", ]; diff --git a/app/Language/tr/Sales.php b/app/Language/tr/Sales.php index 8901fa02f..56f104dac 100644 --- a/app/Language/tr/Sales.php +++ b/app/Language/tr/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "İş Emri Numarası diğerinden farklı olmalı.", "work_order_sent" => "İş Emri gönderildi:", "work_order_unsent" => "İş Emri gönderilemedi:", + "sale_not_found" => "Satış bulunamadı", ]; diff --git a/app/Language/uk/Sales.php b/app/Language/uk/Sales.php index 2524d3a3d..cc266a2db 100644 --- a/app/Language/uk/Sales.php +++ b/app/Language/uk/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "Такий номер робочого замовлення уже існує.", "work_order_sent" => "Замовлення відправлено", "work_order_unsent" => "Не вдалось отримати робоче замовлення", + "sale_not_found" => "Продаж не знайдено", ]; diff --git a/app/Language/vi/Sales.php b/app/Language/vi/Sales.php index d99083c28..79ecf2dfd 100644 --- a/app/Language/vi/Sales.php +++ b/app/Language/vi/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "Số giấy giao việc phải là duy nhất.", "work_order_sent" => "Gửi Giấy giao việc cho", "work_order_unsent" => "Gặp lỗi khi gửi Giấy giao việc cho", + "sale_not_found" => "Không tìm thấy giao dịch", ]; diff --git a/app/Language/zh-Hans/Sales.php b/app/Language/zh-Hans/Sales.php index 4e04d3d09..b15f00b2a 100644 --- a/app/Language/zh-Hans/Sales.php +++ b/app/Language/zh-Hans/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "", "work_order_sent" => "", "work_order_unsent" => "", + "sale_not_found" => "找不到销售", ]; diff --git a/app/Language/zh-Hant/Sales.php b/app/Language/zh-Hant/Sales.php index cb1af0dfb..7c5327c7b 100644 --- a/app/Language/zh-Hant/Sales.php +++ b/app/Language/zh-Hant/Sales.php @@ -222,4 +222,5 @@ return [ "work_order_number_duplicate" => "工作編號重複.", "work_order_sent" => "發送工作指示", "work_order_unsent" => "工作指示發送失敗", + "sale_not_found" => "找不到銷售", ]; diff --git a/app/Libraries/Email_lib.php b/app/Libraries/Email_lib.php index 914344567..607ffd523 100644 --- a/app/Libraries/Email_lib.php +++ b/app/Libraries/Email_lib.php @@ -82,4 +82,37 @@ class Email_lib return $result; } + + /** + * 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; + } } diff --git a/app/Libraries/InvoiceAttachment/InvoiceAttachment.php b/app/Libraries/InvoiceAttachment/InvoiceAttachment.php new file mode 100644 index 000000000..d73edcdda --- /dev/null +++ b/app/Libraries/InvoiceAttachment/InvoiceAttachment.php @@ -0,0 +1,40 @@ +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); + } + } +} \ No newline at end of file diff --git a/app/Libraries/InvoiceAttachment/PdfAttachment.php b/app/Libraries/InvoiceAttachment/PdfAttachment.php new file mode 100644 index 000000000..686fd16cd --- /dev/null +++ b/app/Libraries/InvoiceAttachment/PdfAttachment.php @@ -0,0 +1,61 @@ +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']; + } +} \ No newline at end of file diff --git a/app/Libraries/InvoiceAttachment/UblAttachment.php b/app/Libraries/InvoiceAttachment/UblAttachment.php new file mode 100644 index 000000000..369cd692a --- /dev/null +++ b/app/Libraries/InvoiceAttachment/UblAttachment.php @@ -0,0 +1,69 @@ +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']; + } +} \ No newline at end of file diff --git a/app/Libraries/UBLGenerator.php b/app/Libraries/UBLGenerator.php new file mode 100644 index 000000000..439cb0f24 --- /dev/null +++ b/app/Libraries/UBLGenerator.php @@ -0,0 +1,245 @@ +setId('VAT'); + $supplierParty = $this->buildSupplierParty($saleData, $taxScheme); + $customerParty = $this->buildCustomerParty($saleData['customer_object'] ?? null, $taxScheme); + $invoiceLines = $this->buildInvoiceLines($saleData['cart'], $taxScheme); + $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'])) + ->setInvoiceTypeCode(380) + ->setAccountingSupplierParty($supplierParty) + ->setAccountingCustomerParty($customerParty) + ->setInvoiceLines($invoiceLines) + ->setTaxTotal($taxTotal) + ->setLegalMonetaryTotal($monetaryTotal); + + $generator = new Generator(); + return $generator->invoice($invoice); + } + + protected function buildSupplierParty(array $saleData, TaxScheme $taxScheme): AccountingParty + { + $config = $saleData['config']; + + $addressParts = $this->parseAddress($config['address'] ?? ''); + $countryCode = 'BE'; // Default + + $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); + + if (!empty($config['account_number'])) { + $partyTaxScheme = (new PartyTaxScheme()) + ->setCompanyId($config['account_number']) + ->setTaxScheme($taxScheme); + $party->setPartyTaxScheme($partyTaxScheme); + } + + $accountingParty = (new AccountingParty())->setParty($party); + + return $accountingParty; + } + + 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); + } + + if (!empty($customerInfo->account_number)) { + $accountingParty = (new AccountingParty()) + ->setParty($party) + ->setSupplierAssignedAccountId($customerInfo->account_number); + } else { + $accountingParty = (new AccountingParty())->setParty($party); + } + + if (!empty($customerInfo->tax_id)) { + $partyTaxScheme = (new PartyTaxScheme()) + ->setCompanyId($customerInfo->tax_id) + ->setTaxScheme($taxScheme); + $party->setPartyTaxScheme($partyTaxScheme); + } + + return $accountingParty; + } + + protected function buildInvoiceLines(array $cart, TaxScheme $taxScheme): array + { + $lines = []; + foreach ($cart as $item) { + $price = (new Price()) + ->setBaseQuantity(1.0) + ->setUnitCode(UnitCode::UNIT) + ->setPriceAmount($item['price'] ?? 0); + + $taxCategory = (new TaxCategory()) + ->setId('S') + ->setPercent((float)($item['tax_rate'] ?? 0)) + ->setTaxScheme($taxScheme); + + $itemObj = (new Item()) + ->setName($item['name'] ?? '') + ->setDescription($item['description'] ?? '') + ->setClassifiedTaxCategory($taxCategory); + + $line = (new InvoiceLine()) + ->setId(isset($item['line']) ? (string)$item['line'] : '1') + ->setItem($itemObj) + ->setPrice($price) + ->setInvoicedQuantity($item['quantity'] ?? 0); + + $lines[] = $line; + } + return $lines; + } + + 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); + + $taxCategory = (new TaxCategory()) + ->setId('S') + ->setPercent((float)$taxRate) + ->setTaxScheme($taxScheme); + + $taxableAmount = '0'; + if (bccomp($taxRate, '0') > 0) { + $taxableAmount = bcdiv($taxAmount, bcdiv($taxRate, '100'), 10); + } + + $taxSubTotal = (new TaxSubTotal()) + ->setTaxableAmount((float)$taxableAmount) + ->setTaxAmount((float)$taxAmount) + ->setTaxCategory($taxCategory); + + $taxSubTotals[] = $taxSubTotal; + $totalTax = bcadd($totalTax, $taxAmount); + } + } + + $taxTotal = new TaxTotal(); + $taxTotal->setTaxAmount((float)$totalTax); + foreach ($taxSubTotals as $subTotal) { + $taxTotal->addTaxSubTotal($subTotal); + } + + return $taxTotal; + } + + protected function buildMonetaryTotal(array $saleData): LegalMonetaryTotal + { + $subtotal = (string)($saleData['subtotal'] ?? 0); + $total = (string)($saleData['total'] ?? 0); + $amountDue = (string)($saleData['amount_due'] ?? 0); + + return (new LegalMonetaryTotal()) + ->setLineExtensionAmount((float)$subtotal) + ->setTaxExclusiveAmount((float)$subtotal) + ->setTaxInclusiveAmount((float)$total) + ->setPayableAmount((float)$amountDue); + } + + 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 + // Note: This handles common European formats. International formats + // like UK postcodes (e.g., "SW1A 2AA") may need additional handling. + 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; + } +} \ No newline at end of file diff --git a/app/Views/sales/invoice.php b/app/Views/sales/invoice.php index 654dab96f..d4c2134cd 100644 --- a/app/Views/sales/invoice.php +++ b/app/Views/sales/invoice.php @@ -69,6 +69,7 @@ if (isset($error_message)) {
 ' . lang('Sales.send_invoice') ?>
+  ' . lang('Sales.download_ubl'), ['class' => 'btn btn-info btn-sm']) ?>  ' . lang('Sales.register'), ['class' => 'btn btn-info btn-sm', 'id' => 'show_sales_button']) ?>  ' . lang('Sales.takings'), ['class' => 'btn btn-info btn-sm', 'id' => 'show_takings_button']) ?> diff --git a/composer.json b/composer.json index 296c5b921..881a52cc8 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "dompdf/dompdf": "^2.0.3", "ezyang/htmlpurifier": "^4.17", "laminas/laminas-escaper": "2.17.0", + "num-num/ubl-invoice": "^2.4", "paragonie/random_compat": "^2.0.21", "picqer/php-barcode-generator": "^2.4.0", "tamtamchik/namecase": "^3.0.0" diff --git a/tests/Libraries/InvoiceAttachment/InvoiceAttachmentGeneratorTest.php b/tests/Libraries/InvoiceAttachment/InvoiceAttachmentGeneratorTest.php new file mode 100644 index 000000000..84fef6bf2 --- /dev/null +++ b/tests/Libraries/InvoiceAttachment/InvoiceAttachmentGeneratorTest.php @@ -0,0 +1,121 @@ +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); + } +} \ No newline at end of file diff --git a/tests/Libraries/InvoiceAttachment/PdfAttachmentTest.php b/tests/Libraries/InvoiceAttachment/PdfAttachmentTest.php new file mode 100644 index 000000000..6c8b92d07 --- /dev/null +++ b/tests/Libraries/InvoiceAttachment/PdfAttachmentTest.php @@ -0,0 +1,70 @@ +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'])); + } +} \ No newline at end of file diff --git a/tests/Libraries/InvoiceAttachment/UblAttachmentTest.php b/tests/Libraries/InvoiceAttachment/UblAttachmentTest.php new file mode 100644 index 000000000..3546e32d4 --- /dev/null +++ b/tests/Libraries/InvoiceAttachment/UblAttachmentTest.php @@ -0,0 +1,103 @@ +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); + } +} \ No newline at end of file