mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-04-02 14:24:27 -04:00
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
This commit is contained in:
@@ -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
|
||||
*
|
||||
|
||||
24
app/Database/Migrations/20260304000000_addUblConfig.php
Normal file
24
app/Database/Migrations/20260304000000_addUblConfig.php
Normal 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();
|
||||
}
|
||||
}
|
||||
229
app/Helpers/country_helper.php
Normal file
229
app/Helpers/country_helper.php
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
'<span class="glyphicon glyphicon-list-alt"></span>',
|
||||
['title' => lang('Sales.show_invoice')]
|
||||
);
|
||||
$row['ubl'] = empty($sale->invoice_number)
|
||||
? '-'
|
||||
: anchor(
|
||||
"$controller/getUBLInvoice/$sale->sale_id",
|
||||
'<span class="glyphicon glyphicon-download"></span>',
|
||||
['title' => lang('Sales.download_ubl'), 'target' => '_blank']
|
||||
);
|
||||
}
|
||||
|
||||
$row['receipt'] = anchor(
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "رقم طلب العمل يجب ان يكون فريد.",
|
||||
"work_order_sent" => "تم ارسال طلب العمل الى",
|
||||
"work_order_unsent" => "فشل في ارسال طلب العمل الى",
|
||||
"sale_not_found" => "البيع غير موجود",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "رقم طلب العمل يجب ان يكون فريد.",
|
||||
"work_order_sent" => "تم ارسال طلب العمل الى",
|
||||
"work_order_unsent" => "فشل في ارسال طلب العمل الى",
|
||||
"sale_not_found" => "البيع غير موجود",
|
||||
];
|
||||
|
||||
@@ -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ı",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "Номерът на работната поръчка трябва да е уникален.",
|
||||
"work_order_sent" => "Работната поръчка е изпратена до",
|
||||
"work_order_unsent" => "Работната поръчка не бе изпратена до",
|
||||
"sale_not_found" => "Продажбата не е намерена",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -223,4 +223,5 @@ return [
|
||||
'work_order_number_duplicate' => "ژمارەی داواکاری کار دەبێت تایبەت بێت.",
|
||||
'work_order_sent' => "داواکاری کار نێردرا بۆ",
|
||||
'work_order_unsent' => "داواکاری کار شکستی هێنا لە ناردنی بۆ",
|
||||
'sale_not_found' => "فرۆش نەدۆزرایەوە",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "",
|
||||
"work_order_sent" => "",
|
||||
"work_order_unsent" => "",
|
||||
"sale_not_found" => "Prodej nenalezen",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "",
|
||||
"work_order_sent" => "",
|
||||
"work_order_unsent" => "",
|
||||
"sale_not_found" => "Salg ikke fundet",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "",
|
||||
"work_order_sent" => "",
|
||||
"work_order_unsent" => "",
|
||||
"sale_not_found" => "Verkauf nicht gefunden",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "Ο Αριθμός της Παραγγελίας Εργασίας πρέπει να είναι μοναδικός.",
|
||||
"work_order_sent" => "Εντολή Εργασίας απεστάλη σε",
|
||||
"work_order_unsent" => "Ανεπιτυχής αποστολή Εντολής Εργασίας",
|
||||
"sale_not_found" => "Η πώληση δεν βρέθηκε",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "شماره سفارش کار باید منحصر به فرد باشد.",
|
||||
"work_order_sent" => "دستور کار ارسال شده به",
|
||||
"work_order_unsent" => "دستور کار نتوانست به ارسال شود",
|
||||
"sale_not_found" => "فروش یافت نشد",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "מספר הזמנת עבודה חייב להיות ייחודי.",
|
||||
"work_order_sent" => "הזמנת עבודה נשלחה אל",
|
||||
"work_order_unsent" => "הזמנת עבודה לא נשלחה אל",
|
||||
"sale_not_found" => "המכירה לא נמצאה",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "",
|
||||
"work_order_sent" => "",
|
||||
"work_order_unsent" => "",
|
||||
"sale_not_found" => "Prodaja nije pronađena",
|
||||
];
|
||||
|
||||
@@ -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ó",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "",
|
||||
"work_order_sent" => "",
|
||||
"work_order_unsent" => "",
|
||||
"sale_not_found" => "Վաճառքը գտնված չէ",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "",
|
||||
"work_order_sent" => "",
|
||||
"work_order_unsent" => "",
|
||||
"sale_not_found" => "Salg ikke funnet",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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ă",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "Номер рабочего заказа должен быть уникальным.",
|
||||
"work_order_sent" => "Рабочий заказ отправлен",
|
||||
"work_order_unsent" => "Не удалось отправить рабочий заказ",
|
||||
"sale_not_found" => "Продажа не найдена",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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",
|
||||
];
|
||||
@@ -222,5 +222,6 @@ return [
|
||||
"work_order_number_duplicate" => "หมายเลขคำสั่งงานต้องไม่ซ้ำกัน",
|
||||
"work_order_sent" => "คำสั่งงานส่งถึง",
|
||||
"work_order_unsent" => "ส่งคำสั่งงานล้มเหลว",
|
||||
"sale_not_found" => "ไม่พบการขาย",
|
||||
"selected_customer" => "ลูกค้าที่เลือก",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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ı",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "Такий номер робочого замовлення уже існує.",
|
||||
"work_order_sent" => "Замовлення відправлено",
|
||||
"work_order_unsent" => "Не вдалось отримати робоче замовлення",
|
||||
"sale_not_found" => "Продаж не знайдено",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "",
|
||||
"work_order_sent" => "",
|
||||
"work_order_unsent" => "",
|
||||
"sale_not_found" => "找不到销售",
|
||||
];
|
||||
|
||||
@@ -222,4 +222,5 @@ return [
|
||||
"work_order_number_duplicate" => "工作編號重複.",
|
||||
"work_order_sent" => "發送工作指示",
|
||||
"work_order_unsent" => "工作指示發送失敗",
|
||||
"sale_not_found" => "找不到銷售",
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Libraries/InvoiceAttachment/InvoiceAttachment.php
Normal file
40
app/Libraries/InvoiceAttachment/InvoiceAttachment.php
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/Libraries/InvoiceAttachment/PdfAttachment.php
Normal file
61
app/Libraries/InvoiceAttachment/PdfAttachment.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
69
app/Libraries/InvoiceAttachment/UblAttachment.php
Normal file
69
app/Libraries/InvoiceAttachment/UblAttachment.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
245
app/Libraries/UBLGenerator.php
Normal file
245
app/Libraries/UBLGenerator.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use NumNum\UBL\Invoice;
|
||||
use NumNum\UBL\Generator;
|
||||
use NumNum\UBL\Party;
|
||||
use NumNum\UBL\Address;
|
||||
use NumNum\UBL\Country;
|
||||
use NumNum\UBL\AccountingParty;
|
||||
use NumNum\UBL\PartyTaxScheme;
|
||||
use NumNum\UBL\InvoiceLine;
|
||||
use NumNum\UBL\Item;
|
||||
use NumNum\UBL\Price;
|
||||
use NumNum\UBL\UnitCode;
|
||||
use NumNum\UBL\TaxTotal;
|
||||
use NumNum\UBL\TaxSubTotal;
|
||||
use NumNum\UBL\TaxCategory;
|
||||
use NumNum\UBL\TaxScheme;
|
||||
use NumNum\UBL\LegalMonetaryTotal;
|
||||
use NumNum\UBL\Contact;
|
||||
|
||||
helper(['country']);
|
||||
|
||||
class UBLGenerator
|
||||
{
|
||||
public function generateUblInvoice(array $saleData): string
|
||||
{
|
||||
$taxScheme = (new TaxScheme())->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;
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ if (isset($error_message)) {
|
||||
<div class="btn btn-info btn-sm" id="show_email_button"><?= '<span class="glyphicon glyphicon-envelope"> </span>' . lang('Sales.send_invoice') ?></div>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?= anchor("sales/getUBLInvoice/$sale_id_num", '<span class="glyphicon glyphicon-download"> </span>' . lang('Sales.download_ubl'), ['class' => 'btn btn-info btn-sm']) ?>
|
||||
<?= anchor("sales", '<span class="glyphicon glyphicon-shopping-cart"> </span>' . lang('Sales.register'), ['class' => 'btn btn-info btn-sm', 'id' => 'show_sales_button']) ?>
|
||||
<?= anchor("sales/manage", '<span class="glyphicon glyphicon-list-alt"> </span>' . lang('Sales.takings'), ['class' => 'btn btn-info btn-sm', 'id' => 'show_takings_button']) ?>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
70
tests/Libraries/InvoiceAttachment/PdfAttachmentTest.php
Normal file
70
tests/Libraries/InvoiceAttachment/PdfAttachmentTest.php
Normal 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']));
|
||||
}
|
||||
}
|
||||
103
tests/Libraries/InvoiceAttachment/UblAttachmentTest.php
Normal file
103
tests/Libraries/InvoiceAttachment/UblAttachmentTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user