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:
jekkos
2026-03-04 19:52:20 +00:00
parent e723e2ddf4
commit e6b288c291
49 changed files with 1181 additions and 8 deletions

View File

@@ -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
*

View File

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

View File

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

View File

@@ -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(

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "رقم طلب العمل يجب ان يكون فريد.",
"work_order_sent" => "تم ارسال طلب العمل الى",
"work_order_unsent" => "فشل في ارسال طلب العمل الى",
"sale_not_found" => "البيع غير موجود",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "رقم طلب العمل يجب ان يكون فريد.",
"work_order_sent" => "تم ارسال طلب العمل الى",
"work_order_unsent" => "فشل في ارسال طلب العمل الى",
"sale_not_found" => "البيع غير موجود",
];

View File

@@ -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ı",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "Номерът на работната поръчка трябва да е уникален.",
"work_order_sent" => "Работната поръчка е изпратена до",
"work_order_unsent" => "Работната поръчка не бе изпратена до",
"sale_not_found" => "Продажбата не е намерена",
];

View File

@@ -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",
];

View File

@@ -223,4 +223,5 @@ return [
'work_order_number_duplicate' => "ژمارەی داواکاری کار دەبێت تایبەت بێت.",
'work_order_sent' => "داواکاری کار نێردرا بۆ",
'work_order_unsent' => "داواکاری کار شکستی هێنا لە ناردنی بۆ",
'sale_not_found' => "فرۆش نەدۆزرایەوە",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "",
"work_order_sent" => "",
"work_order_unsent" => "",
"sale_not_found" => "Prodej nenalezen",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "",
"work_order_sent" => "",
"work_order_unsent" => "",
"sale_not_found" => "Salg ikke fundet",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "",
"work_order_sent" => "",
"work_order_unsent" => "",
"sale_not_found" => "Verkauf nicht gefunden",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "Ο Αριθμός της Παραγγελίας Εργασίας πρέπει να είναι μοναδικός.",
"work_order_sent" => "Εντολή Εργασίας απεστάλη σε",
"work_order_unsent" => "Ανεπιτυχής αποστολή Εντολής Εργασίας",
"sale_not_found" => "Η πώληση δεν βρέθηκε",
];

View File

@@ -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",
];

View File

@@ -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",
];

View File

@@ -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",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "شماره سفارش کار باید منحصر به فرد باشد.",
"work_order_sent" => "دستور کار ارسال شده به",
"work_order_unsent" => "دستور کار نتوانست به ارسال شود",
"sale_not_found" => "فروش یافت نشد",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "מספר הזמנת עבודה חייב להיות ייחודי.",
"work_order_sent" => "הזמנת עבודה נשלחה אל",
"work_order_unsent" => "הזמנת עבודה לא נשלחה אל",
"sale_not_found" => "המכירה לא נמצאה",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "",
"work_order_sent" => "",
"work_order_unsent" => "",
"sale_not_found" => "Prodaja nije pronađena",
];

View File

@@ -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ó",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "",
"work_order_sent" => "",
"work_order_unsent" => "",
"sale_not_found" => "Վաճառքը գտնված չէ",
];

View File

@@ -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",
];

View File

@@ -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",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "",
"work_order_sent" => "",
"work_order_unsent" => "",
"sale_not_found" => "Salg ikke funnet",
];

View File

@@ -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",
];

View File

@@ -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ă",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "Номер рабочего заказа должен быть уникальным.",
"work_order_sent" => "Рабочий заказ отправлен",
"work_order_unsent" => "Не удалось отправить рабочий заказ",
"sale_not_found" => "Продажа не найдена",
];

View File

@@ -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",
];

View File

@@ -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",
];

View File

@@ -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",
];

View File

@@ -222,5 +222,6 @@ return [
"work_order_number_duplicate" => "หมายเลขคำสั่งงานต้องไม่ซ้ำกัน",
"work_order_sent" => "คำสั่งงานส่งถึง",
"work_order_unsent" => "ส่งคำสั่งงานล้มเหลว",
"sale_not_found" => "ไม่พบการขาย",
"selected_customer" => "ลูกค้าที่เลือก",
];

View File

@@ -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",
];

View File

@@ -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ı",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "Такий номер робочого замовлення уже існує.",
"work_order_sent" => "Замовлення відправлено",
"work_order_unsent" => "Не вдалось отримати робоче замовлення",
"sale_not_found" => "Продаж не знайдено",
];

View File

@@ -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",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "",
"work_order_sent" => "",
"work_order_unsent" => "",
"sale_not_found" => "找不到销售",
];

View File

@@ -222,4 +222,5 @@ return [
"work_order_number_duplicate" => "工作編號重複.",
"work_order_sent" => "發送工作指示",
"work_order_unsent" => "工作指示發送失敗",
"sale_not_found" => "找不到銷售",
];

View File

@@ -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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

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