diff --git a/services/platform/apps/billing/invoice_models.py b/services/platform/apps/billing/invoice_models.py index 0aa3cfc4..380b414b 100644 --- a/services/platform/apps/billing/invoice_models.py +++ b/services/platform/apps/billing/invoice_models.py @@ -134,6 +134,7 @@ class Invoice(models.Model): # Billing address snapshot (immutable once issued) bill_to_name = models.CharField(max_length=255, default="") bill_to_tax_id = models.CharField(max_length=50, blank=True) + bill_to_registration_number = models.CharField(max_length=50, blank=True) # Nr. Reg. Com. / J number bill_to_email = models.EmailField(blank=True) bill_to_address1 = models.CharField(max_length=255, blank=True) bill_to_address2 = models.CharField(max_length=255, blank=True) diff --git a/services/platform/apps/billing/migrations/0025_add_bill_to_registration_number.py b/services/platform/apps/billing/migrations/0025_add_bill_to_registration_number.py new file mode 100644 index 00000000..53aa17a5 --- /dev/null +++ b/services/platform/apps/billing/migrations/0025_add_bill_to_registration_number.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("billing", "0024_backfill_refunds_from_meta"), + ] + + operations = [ + migrations.AddField( + model_name="invoice", + name="bill_to_registration_number", + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name="proformainvoice", + name="bill_to_registration_number", + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/services/platform/apps/billing/pdf_generators.py b/services/platform/apps/billing/pdf_generators.py index 6bfcf392..fbca52db 100644 --- a/services/platform/apps/billing/pdf_generators.py +++ b/services/platform/apps/billing/pdf_generators.py @@ -1,9 +1,12 @@ # =============================================================================== # ROMANIAN PDF GENERATORS FOR BILLING DOCUMENTS +# EN16931-compliant with Romanian Cod Fiscal art. 319 / art. 331 support # =============================================================================== from __future__ import annotations +from collections import defaultdict +from decimal import Decimal from io import BytesIO from django.conf import settings @@ -20,6 +23,7 @@ class RomanianDocumentPDFGenerator: """ Base class for Romanian document PDF generation with common functionality. Handles company information, Romanian compliance, and standard formatting. + EN16931-compliant line-level detail and VAT breakdown. """ def __init__(self, document: Invoice | ProformaInvoice) -> None: @@ -27,6 +31,7 @@ def __init__(self, document: Invoice | ProformaInvoice) -> None: self.buffer = BytesIO() self.canvas = canvas.Canvas(self.buffer, pagesize=A4) self.width, self.height = A4 + self._table_end_y: float = 0.0 def generate_response(self) -> HttpResponse: """Generate complete PDF response with proper headers.""" @@ -49,6 +54,12 @@ def _create_pdf_document(self) -> None: self._render_totals_section() self._render_document_footer() + def _get_currency_code(self) -> str: + """Get the currency code from the document, defaulting to RON.""" + if self.document.currency: + return self.document.currency.code + return "RON" + def _get_company_info(self) -> dict[str, str]: """Get company information from settings with Romanian defaults.""" return { @@ -58,6 +69,10 @@ def _get_company_info(self) -> dict[str, str]: "country": getattr(settings, "COMPANY_COUNTRY", "România"), "cui": getattr(settings, "COMPANY_CUI", "RO12345678"), "email": getattr(settings, "COMPANY_EMAIL", "contact@praho.ro"), + "registration_number": getattr(settings, "COMPANY_REGISTRATION_NUMBER", ""), + "bank_name": getattr(settings, "COMPANY_BANK_NAME", ""), + "bank_account": getattr(settings, "COMPANY_BANK_ACCOUNT", ""), + "phone": getattr(settings, "COMPANY_PHONE", ""), } def _setup_document_header(self) -> None: @@ -66,7 +81,7 @@ def _setup_document_header(self) -> None: # Company branding self.canvas.setFont("Helvetica-Bold", 24) - self.canvas.drawString(2 * cm, self.height - 3 * cm, f"🇷🇴 {company_info['name']}") + self.canvas.drawString(2 * cm, self.height - 3 * cm, company_info["name"]) # Document title self.canvas.setFont("Helvetica-Bold", 16) @@ -88,7 +103,7 @@ def _get_filename(self) -> str: raise NotImplementedError("Subclasses must implement filename") def _render_company_information(self) -> None: - """Render supplier (company) information section.""" + """Render supplier (company) information section with full Romanian details.""" company_info = self._get_company_info() y_pos = self.height - 8 * cm @@ -96,39 +111,114 @@ def _render_company_information(self) -> None: self.canvas.drawString(2 * cm, y_pos, str(_t("Supplier:"))) self.canvas.setFont("Helvetica", 10) - self.canvas.drawString(2 * cm, y_pos - 0.5 * cm, company_info["name"]) + step = 0.4 * cm + current_y = y_pos - step + + self.canvas.drawString(2 * cm, current_y, company_info["name"]) + current_y -= step + self.canvas.drawString( - 2 * cm, y_pos - 1 * cm, f"{company_info['address']}, {company_info['city']}, {company_info['country']}" + 2 * cm, current_y, f"{company_info['address']}, {company_info['city']}, {company_info['country']}" ) - self.canvas.drawString(2 * cm, y_pos - 1.5 * cm, str(_t("Tax ID: {cui}")).format(cui=company_info["cui"])) - self.canvas.drawString(2 * cm, y_pos - 2 * cm, str(_t("Email: {email}")).format(email=company_info["email"])) + current_y -= step + + self.canvas.drawString(2 * cm, current_y, str(_t("CUI/CIF: {cui}")).format(cui=company_info["cui"])) + current_y -= step + + if company_info["registration_number"]: + self.canvas.drawString( + 2 * cm, + current_y, + str(_t("Nr. Reg. Com.: {reg}")).format(reg=company_info["registration_number"]), + ) + current_y -= step + + self.canvas.drawString(2 * cm, current_y, str(_t("Email: {email}")).format(email=company_info["email"])) + current_y -= step + + if company_info["phone"]: + self.canvas.drawString(2 * cm, current_y, str(_t("Tel: {phone}")).format(phone=company_info["phone"])) + current_y -= step + + if company_info["bank_name"]: + self.canvas.drawString(2 * cm, current_y, str(_t("Banca: {bank}")).format(bank=company_info["bank_name"])) + current_y -= step + + if company_info["bank_account"]: + self.canvas.drawString(2 * cm, current_y, str(_t("IBAN: {iban}")).format(iban=company_info["bank_account"])) def _render_client_information(self) -> None: - """Render client information section.""" + """Render client information section with full address and tax details.""" y_pos = self.height - 8 * cm + x_pos = 11 * cm + step = 0.4 * cm self.canvas.setFont("Helvetica-Bold", 14) - self.canvas.drawString(11 * cm, y_pos, str(_t("Client:"))) + self.canvas.drawString(x_pos, y_pos, str(_t("Client:"))) self.canvas.setFont("Helvetica", 10) - self.canvas.drawString(11 * cm, y_pos - 0.5 * cm, self.document.bill_to_name or "") + current_y = y_pos - step - if self.document.bill_to_address1: - self.canvas.drawString(11 * cm, y_pos - 1 * cm, self.document.bill_to_address1) + # Name + self.canvas.drawString(x_pos, current_y, self.document.bill_to_name or "") + current_y -= step + # Address line 1 + if self.document.bill_to_address1: + self.canvas.drawString(x_pos, current_y, self.document.bill_to_address1) + current_y -= step + + # Address line 2 + if self.document.bill_to_address2: + self.canvas.drawString(x_pos, current_y, self.document.bill_to_address2) + current_y -= step + + # City, region, postal code + city_parts = [] + if self.document.bill_to_city: + city_parts.append(self.document.bill_to_city) + region_postal = "" + if self.document.bill_to_region: + region_postal += self.document.bill_to_region + if self.document.bill_to_postal: + region_postal += f" {self.document.bill_to_postal}" if region_postal else self.document.bill_to_postal + if region_postal: + city_parts.append(region_postal) + + if city_parts: + self.canvas.drawString(x_pos, current_y, ", ".join(city_parts)) + current_y -= step + + # Country + if self.document.bill_to_country: + self.canvas.drawString(x_pos, current_y, self.document.bill_to_country) + current_y -= step + + # Tax ID if self.document.bill_to_tax_id: self.canvas.drawString( - 11 * cm, y_pos - 1.5 * cm, str(_t("Tax ID: {tax_id}")).format(tax_id=self.document.bill_to_tax_id) + x_pos, current_y, str(_t("CUI/CIF: {tax_id}")).format(tax_id=self.document.bill_to_tax_id) ) + current_y -= step + # Registration number + if self.document.bill_to_registration_number: + self.canvas.drawString( + x_pos, + current_y, + str(_t("Nr. Reg. Com.: {reg}")).format(reg=self.document.bill_to_registration_number), + ) + current_y -= step + + # Email if self.document.bill_to_email: self.canvas.drawString( - 11 * cm, y_pos - 2 * cm, str(_t("Email: {email}")).format(email=self.document.bill_to_email) + x_pos, current_y, str(_t("Email: {email}")).format(email=self.document.bill_to_email) ) def _render_items_table(self) -> None: """Render items table with headers and line items.""" - table_y = self.height - 13 * cm + table_y = self.height - 15 * cm # Table headers self._render_table_headers(table_y) @@ -137,62 +227,168 @@ def _render_items_table(self) -> None: self._render_table_data(table_y) def _render_table_headers(self, table_y: float) -> None: - """Render table column headers.""" + """Render table column headers with VAT% column.""" self.canvas.setFont("Helvetica-Bold", 10) self.canvas.drawString(2 * cm, table_y, str(_t("Description"))) - self.canvas.drawString(10 * cm, table_y, str(_t("Quantity"))) - self.canvas.drawString(12 * cm, table_y, str(_t("Unit Price"))) - self.canvas.drawString(15 * cm, table_y, str(_t("Total"))) + self.canvas.drawString(9 * cm, table_y, str(_t("Qty"))) + self.canvas.drawString(11 * cm, table_y, str(_t("Unit Price"))) + self.canvas.drawString(13.5 * cm, table_y, str(_t("VAT%"))) + self.canvas.drawString(15.5 * cm, table_y, str(_t("Total"))) # Draw line under headers self.canvas.line(2 * cm, table_y - 0.3 * cm, 18 * cm, table_y - 0.3 * cm) def _render_table_data(self, table_y: float) -> None: - """Render table line items data.""" - self.canvas.setFont("Helvetica", 9) + """Render table line items data with EN16931 sub-line details.""" + currency = self._get_currency_code() current_y = table_y - 0.8 * cm lines = self.document.lines.all() for line in lines: - self.canvas.drawString(2 * cm, current_y, str(line.description)[:40]) # Truncate long descriptions - self.canvas.drawString(10 * cm, current_y, f"{line.quantity:.2f}") - self.canvas.drawString(12 * cm, current_y, f"{line.unit_price:.2f} RON") - self.canvas.drawString(15 * cm, current_y, f"{line.line_total:.2f} RON") + # Main line + self.canvas.setFont("Helvetica", 9) + self.canvas.drawString(2 * cm, current_y, str(line.description)[:40]) + self.canvas.drawString(9 * cm, current_y, f"{line.quantity:.2f}") + self.canvas.drawString(11 * cm, current_y, f"{line.unit_price:.2f} {currency}") + + vat_pct = int(line.tax_rate * 100) + self.canvas.drawString(13.5 * cm, current_y, f"{vat_pct}%") + self.canvas.drawString(15.5 * cm, current_y, f"{line.line_total:.2f} {currency}") current_y -= 0.5 * cm + # Sub-lines (EN16931 detail fields) in smaller font, indented + self.canvas.setFont("Helvetica", 8) + + if line.domain_name: + self.canvas.drawString( + 2.5 * cm, current_y, str(_t("Domeniu: {domain}")).format(domain=line.domain_name) + ) + current_y -= 0.35 * cm + + if line.period_start and line.period_end: + self.canvas.drawString( + 2.5 * cm, + current_y, + str(_t("Perioada: {start} - {end}")).format( + start=line.period_start.strftime("%d.%m.%Y"), + end=line.period_end.strftime("%d.%m.%Y"), + ), + ) + current_y -= 0.35 * cm + + if line.seller_item_id: + self.canvas.drawString( + 2.5 * cm, current_y, str(_t("Cod produs: {code}")).format(code=line.seller_item_id) + ) + current_y -= 0.35 * cm + + if line.discount_amount_cents > 0: + discount_display = Decimal(line.discount_amount_cents) / 100 + self.canvas.drawString( + 2.5 * cm, + current_y, + str(_t("Discount: -{amount} {currency}")).format( + amount=f"{discount_display:.2f}", currency=currency + ), + ) + current_y -= 0.35 * cm + + self._table_end_y = current_y + def _render_totals_section(self) -> None: - """Render totals section with Romanian VAT calculations.""" - # Calculate position after table items - lines_count = self.document.lines.count() - current_y = self.height - 13 * cm - 0.8 * cm - (lines_count * 0.5 * cm) - totals_y = current_y - 1 * cm + """Render totals section with VAT breakdown by rate (EN16931-compliant).""" + currency = self._get_currency_code() + totals_y = self._table_end_y - 1 * cm + # VAT breakdown by rate + vat_groups: dict[int, dict[str, Decimal]] = defaultdict(lambda: {"base": Decimal("0"), "tax": Decimal("0")}) + has_reverse_charge = False + + lines = self.document.lines.all() + for line in lines: + rate_key = int(line.tax_rate * 100) + vat_groups[rate_key]["base"] += line.subtotal + tax_for_line = line.line_total - line.subtotal + vat_groups[rate_key]["tax"] += tax_for_line + + if getattr(line, "tax_category_code", "") == "AE": + has_reverse_charge = True + + # Subtotal self.canvas.setFont("Helvetica-Bold", 12) self.canvas.drawString( - 12 * cm, totals_y, str(_t("Subtotal: {amount} RON")).format(amount=f"{self.document.subtotal:.2f}") + 12 * cm, + totals_y, + str(_t("Subtotal: {amount} {currency}")).format(amount=f"{self.document.subtotal:.2f}", currency=currency), ) - # Use document's effective tax rate for label; fall back to 21% - vat_pct = 21 - if hasattr(self.document, "lines"): - first_line = self.document.lines.first() - if first_line and first_line.tax_rate: - vat_pct = int(first_line.tax_rate * 100) + totals_y -= 0.5 * cm + + # Per-rate VAT lines + self.canvas.setFont("Helvetica", 11) + for rate in sorted(vat_groups.keys()): + group = vat_groups[rate] + self.canvas.drawString( + 12 * cm, + totals_y, + str(_t("TVA {rate}%: {tax} {currency} (baza: {base} {currency})")).format( + rate=rate, + tax=f"{group['tax']:.2f}", + base=f"{group['base']:.2f}", + currency=currency, + ), + ) + totals_y -= 0.5 * cm + + # Total VAT + self.canvas.setFont("Helvetica-Bold", 11) self.canvas.drawString( 12 * cm, - totals_y - 0.5 * cm, - str(_t("VAT ({pct}%): {amount} RON")).format(pct=vat_pct, amount=f"{self.document.tax_amount:.2f}"), + totals_y, + str(_t("Total TVA: {amount} {currency}")).format( + amount=f"{self.document.tax_amount:.2f}", currency=currency + ), ) + totals_y -= 0.6 * cm - # Document-specific total label + # Grand total total_label = self._get_total_label() - self.canvas.drawString(12 * cm, totals_y - 1 * cm, str(total_label).format(amount=f"{self.document.total:.2f}")) + self.canvas.setFont("Helvetica-Bold", 12) + self.canvas.drawString( + 12 * cm, + totals_y, + str(total_label).format(amount=f"{self.document.total:.2f}", currency=currency), + ) + totals_y -= 0.8 * cm + + # Reverse charge notice + if has_reverse_charge: + self.canvas.setFont("Helvetica-Bold", 9) + self.canvas.drawString( + 2 * cm, + totals_y, + str(_t("Taxare inversă / Reverse charge — Art. 331 Cod Fiscal")), + ) + totals_y -= 0.5 * cm + + # Exchange rate line for non-RON currencies + if currency != "RON": + meta = self.document.meta or {} + exchange_rate = meta.get("exchange_rate") + if exchange_rate: + self.canvas.setFont("Helvetica", 9) + self.canvas.drawString( + 2 * cm, + totals_y, + str(_t("Curs valutar: 1 {currency} = {rate} RON")).format(currency=currency, rate=exchange_rate), + ) + totals_y -= 0.5 * cm # Additional status information self._render_status_information(totals_y) def _get_total_label(self) -> str: """Get the appropriate total label for the document type.""" - return _t("TOTAL: {amount} RON") + return _t("TOTAL: {amount} {currency}") def _render_status_information(self, totals_y: float) -> None: """Render document-specific status information.""" @@ -230,10 +426,10 @@ def _get_filename(self) -> str: return f"factura_{self.invoice.number}.pdf" def _get_legal_disclaimer(self) -> str: - return _t("Fiscal invoice issued according to Romanian legislation.") + return _t("Factură fiscală emisă conform art. 319 din Legea nr. 227/2015 privind Codul fiscal.") def _get_total_label(self) -> str: - return _t("TOTAL TO PAY: {amount} RON") + return _t("TOTAL TO PAY: {amount} {currency}") def _render_document_details(self) -> None: """Render invoice-specific details.""" @@ -268,14 +464,14 @@ def _render_status_information(self, totals_y: float) -> None: self.canvas.setFont("Helvetica-Bold", 10) due_date_str = self.invoice.due_at.strftime("%d.%m.%Y") if self.invoice.due_at else str(_t("undefined")) self.canvas.drawString( - 2 * cm, totals_y - 2 * cm, str(_t("⚠️ Unpaid invoice - Due: {date}")).format(date=due_date_str) + 2 * cm, totals_y - 0.5 * cm, str(_t("Unpaid invoice - Due: {date}")).format(date=due_date_str) ) elif self.invoice.status == "paid" and hasattr(self.invoice, "paid_at") and self.invoice.paid_at: self.canvas.setFont("Helvetica-Bold", 10) self.canvas.drawString( 2 * cm, - totals_y - 2 * cm, - str(_t("✅ Invoice paid on: {date}")).format(date=self.invoice.paid_at.strftime("%d.%m.%Y")), + totals_y - 0.5 * cm, + str(_t("Invoice paid on: {date}")).format(date=self.invoice.paid_at.strftime("%d.%m.%Y")), ) @@ -296,7 +492,7 @@ def _get_filename(self) -> str: return f"proforma_{self.proforma.number}.pdf" def _get_legal_disclaimer(self) -> str: - return _t("This proforma is not a fiscal invoice.") + return _t("Factura proforma nu constituie document fiscal. Nu dă drept de deducere a TVA.") def _render_document_details(self) -> None: """Render proforma-specific details.""" diff --git a/services/platform/apps/billing/proforma_models.py b/services/platform/apps/billing/proforma_models.py index 9796d142..3d7257e8 100644 --- a/services/platform/apps/billing/proforma_models.py +++ b/services/platform/apps/billing/proforma_models.py @@ -127,6 +127,7 @@ class ProformaInvoice(models.Model): # Billing address snapshot bill_to_name = models.CharField(max_length=255, default="") bill_to_tax_id = models.CharField(max_length=50, blank=True) + bill_to_registration_number = models.CharField(max_length=50, blank=True) # Nr. Reg. Com. / J number bill_to_email = models.EmailField(blank=True) bill_to_address1 = models.CharField(max_length=255, blank=True) bill_to_address2 = models.CharField(max_length=255, blank=True) diff --git a/services/platform/config/settings/base.py b/services/platform/config/settings/base.py index ee1b17e0..efaccae3 100644 --- a/services/platform/config/settings/base.py +++ b/services/platform/config/settings/base.py @@ -471,6 +471,9 @@ def validate_production_secret_key(secret_key: str | None) -> None: COMPANY_EMAIL = os.environ.get("COMPANY_EMAIL", "contact@praho.ro") COMPANY_PHONE = os.environ.get("COMPANY_PHONE", "+40 21 000 0000") COMPANY_WEBSITE = os.environ.get("COMPANY_WEBSITE", "https://praho.ro") +COMPANY_REGISTRATION_NUMBER = os.environ.get("COMPANY_REGISTRATION_NUMBER", "J40/1234/2020") # Nr. Reg. Com. +COMPANY_BANK_NAME = os.environ.get("COMPANY_BANK_NAME", "Banca Transilvania") +COMPANY_BANK_ACCOUNT = os.environ.get("COMPANY_BANK_ACCOUNT", "RO49AAAA1B31007593840000") # IBAN # VAT settings for Romanian compliance # NOTE: VAT_RATE removed — use TaxService.get_vat_rate('RO'). See ADR-0005, ADR-0015. diff --git a/services/platform/tests/billing/test_pdf_generators.py b/services/platform/tests/billing/test_pdf_generators.py index c247b065..4408d7a4 100644 --- a/services/platform/tests/billing/test_pdf_generators.py +++ b/services/platform/tests/billing/test_pdf_generators.py @@ -2,6 +2,7 @@ # COMPREHENSIVE BILLING PDF GENERATORS TESTS # =============================================================================== +from datetime import date from decimal import Decimal from io import BytesIO from unittest.mock import Mock, patch @@ -19,6 +20,7 @@ RomanianDocumentPDFGenerator, RomanianInvoicePDFGenerator, RomanianProformaPDFGenerator, + generate_invoice_pdf, ) from apps.customers.models import Customer from tests.helpers.fsm_helpers import force_status @@ -88,6 +90,10 @@ def test_get_company_info_defaults(self): self.assertIsNotNone(company_info['country']) self.assertIsNotNone(company_info['cui']) self.assertIsNotNone(company_info['email']) + self.assertIsNotNone(company_info['registration_number']) + self.assertIsNotNone(company_info['bank_name']) + self.assertIsNotNone(company_info['bank_account']) + self.assertIsNotNone(company_info['phone']) # Test that values match current project settings (not hard-coded defaults) self.assertEqual(company_info['name'], settings.COMPANY_NAME) @@ -99,7 +105,11 @@ def test_get_company_info_defaults(self): COMPANY_CITY='Test City', COMPANY_COUNTRY='Test Country', COMPANY_CUI='RO87654321', - COMPANY_EMAIL='test@company.ro' + COMPANY_EMAIL='test@company.ro', + COMPANY_REGISTRATION_NUMBER='J40/999/2024', + COMPANY_BANK_NAME='Test Bank', + COMPANY_BANK_ACCOUNT='RO00TEST0000000000', + COMPANY_PHONE='+40 700 000 000' ) def test_get_company_info_from_settings(self): """Test _get_company_info with custom settings""" @@ -112,6 +122,10 @@ def test_get_company_info_from_settings(self): self.assertEqual(company_info['country'], 'Test Country') self.assertEqual(company_info['cui'], 'RO87654321') self.assertEqual(company_info['email'], 'test@company.ro') + self.assertEqual(company_info['registration_number'], 'J40/999/2024') + self.assertEqual(company_info['bank_name'], 'Test Bank') + self.assertEqual(company_info['bank_account'], 'RO00TEST0000000000') + self.assertEqual(company_info['phone'], '+40 700 000 000') def test_setup_document_header(self): """Test _setup_document_header method""" @@ -296,7 +310,7 @@ def test_get_legal_disclaimer(self): generator = RomanianInvoicePDFGenerator(self.invoice) disclaimer = generator._get_legal_disclaimer() - self.assertIn('Romanian legislation', disclaimer) + self.assertIn('art. 319', disclaimer) def test_get_total_label(self): """Test _get_total_label method""" @@ -446,7 +460,7 @@ def test_get_legal_disclaimer(self): generator = RomanianProformaPDFGenerator(self.proforma) disclaimer = generator._get_legal_disclaimer() - self.assertIn('not a fiscal invoice', disclaimer) + self.assertIn('nu constituie document fiscal', disclaimer) def test_render_document_details(self): """Test _render_document_details method""" @@ -967,6 +981,201 @@ def test_canvas_drawing_operations(self): # Test line drawing generator.canvas.line(50, 30, 100, 30) + +class EN16931PDFComplianceTests(TestCase): + """Tests for EN16931 and Romanian law compliance in PDF generation.""" + + def setUp(self): + self.currency_ron, _ = Currency.objects.get_or_create( + code='RON', defaults={'symbol': 'lei', 'decimals': 2} + ) + self.currency_eur, _ = Currency.objects.get_or_create( + code='EUR', defaults={'symbol': '€', 'decimals': 2} + ) + self.customer = Customer.objects.create( + customer_type='company', + company_name='EN16931 Test SRL', + primary_email='en16931@test.ro', + status='active', + ) + self.invoice = Invoice.objects.create( + customer=self.customer, + currency=self.currency_ron, + number='INV-EN16931-001', + subtotal_cents=10000, + tax_cents=2100, + total_cents=12100, + status='issued', + bill_to_name='Client Test SRL', + bill_to_email='client@test.ro', + bill_to_tax_id='RO99887766', + bill_to_registration_number='J40/555/2023', + bill_to_address1='Strada Test 42', + bill_to_address2='Etaj 3, Ap. 12', + bill_to_city='Cluj-Napoca', + bill_to_region='Cluj', + bill_to_postal='400001', + bill_to_country='RO', + ) + + def _generate_pdf_bytes(self, invoice=None): + """Generate PDF bytes and return them.""" + return generate_invoice_pdf(invoice or self.invoice) + + def _get_canvas_calls(self, invoice=None): + """Generate PDF and capture all drawString calls as a single text blob.""" + generator = RomanianInvoicePDFGenerator(invoice or self.invoice) + calls = [] + original_draw = generator.canvas.drawString + def capture_draw(x, y, text): + calls.append(text) + return original_draw(x, y, text) + generator.canvas.drawString = capture_draw + generator._create_pdf_document() + return calls + + def test_seller_bank_details_rendered(self): + """Seller section must show IBAN and bank name.""" + calls = self._get_canvas_calls() + text = ' '.join(calls) + self.assertIn('IBAN', text) + self.assertIn('Banca', text) + + def test_seller_registration_number_rendered(self): + """Seller section must show Nr. Reg. Com.""" + calls = self._get_canvas_calls() + text = ' '.join(calls) + self.assertIn('Nr. Reg. Com.', text) + + def test_client_full_address_rendered(self): + """Client section must show full address including address2, city, region, postal.""" + calls = self._get_canvas_calls() + text = ' '.join(calls) + self.assertIn('Strada Test 42', text) + self.assertIn('Etaj 3, Ap. 12', text) + self.assertIn('Cluj-Napoca', text) + self.assertIn('400001', text) + + def test_client_registration_number_rendered(self): + """Client section must show bill_to_registration_number when set.""" + calls = self._get_canvas_calls() + text = ' '.join(calls) + self.assertIn('J40/555/2023', text) + + def test_client_tax_id_rendered(self): + """Client section must show CUI/CIF.""" + calls = self._get_canvas_calls() + text = ' '.join(calls) + self.assertIn('RO99887766', text) + + def test_vat_breakdown_multiple_rates(self): + """Totals section must show VAT breakdown per rate when multiple rates exist.""" + # Add lines with different VAT rates + InvoiceLine.objects.create( + invoice=self.invoice, kind='service', description='Standard VAT service', + quantity=1, unit_price_cents=10000, tax_rate=Decimal('0.21'), + tax_cents=2100, line_total_cents=12100, + ) + InvoiceLine.objects.create( + invoice=self.invoice, kind='service', description='Reduced VAT service', + quantity=1, unit_price_cents=5000, tax_rate=Decimal('0.09'), + tax_cents=450, line_total_cents=5450, + ) + calls = self._get_canvas_calls() + text = ' '.join(calls) + self.assertIn('TVA 21%', text) + self.assertIn('TVA 9%', text) + + def test_reverse_charge_notation(self): + """Reverse charge notice must appear when any line has tax_category_code=AE.""" + InvoiceLine.objects.create( + invoice=self.invoice, kind='service', description='EU B2B service', + quantity=1, unit_price_cents=10000, tax_rate=Decimal('0.00'), + tax_cents=0, line_total_cents=10000, + tax_category_code='AE', + ) + calls = self._get_canvas_calls() + text = ' '.join(calls) + self.assertIn('Taxare invers', text) # Partial match avoids encoding issues + + def test_currency_code_not_hardcoded(self): + """Line totals must use the invoice's currency code, not hardcoded RON.""" + eur_invoice = Invoice.objects.create( + customer=self.customer, currency=self.currency_eur, + number='INV-EUR-001', subtotal_cents=10000, tax_cents=2100, + total_cents=12100, status='issued', bill_to_name='EUR Client', + ) + InvoiceLine.objects.create( + invoice=eur_invoice, kind='service', description='EUR service', + quantity=1, unit_price_cents=10000, tax_rate=Decimal('0.21'), + tax_cents=2100, line_total_cents=12100, + ) + calls = self._get_canvas_calls(invoice=eur_invoice) + text = ' '.join(calls) + self.assertIn('EUR', text) + + def test_exchange_rate_shown_for_non_ron(self): + """Exchange rate must be shown when invoice currency is not RON.""" + eur_invoice = Invoice.objects.create( + customer=self.customer, currency=self.currency_eur, + number='INV-EUR-002', subtotal_cents=10000, tax_cents=2100, + total_cents=12100, status='issued', bill_to_name='EUR Client', + meta={'exchange_rate': '4.9750'}, + ) + InvoiceLine.objects.create( + invoice=eur_invoice, kind='service', description='EUR service', + quantity=1, unit_price_cents=10000, tax_rate=Decimal('0.21'), + tax_cents=2100, line_total_cents=12100, + ) + calls = self._get_canvas_calls(invoice=eur_invoice) + text = ' '.join(calls) + self.assertIn('4.9750', text) + self.assertIn('Curs valutar', text) + + def test_line_level_en16931_fields(self): + """EN16931 line-level fields (domain, period, SKU) must be rendered.""" + InvoiceLine.objects.create( + invoice=self.invoice, kind='service', description='Hosting Plan', + quantity=1, unit_price_cents=10000, tax_rate=Decimal('0.21'), + tax_cents=2100, line_total_cents=12100, + domain_name='example.ro', + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 31), + seller_item_id='HOST-PRO-001', + ) + calls = self._get_canvas_calls() + text = ' '.join(calls) + self.assertIn('example.ro', text) + self.assertIn('01.01.2026', text) + self.assertIn('31.01.2026', text) + self.assertIn('HOST-PRO-001', text) + + def test_fiscal_invoice_disclaimer(self): + """Invoice PDF must contain proper Romanian fiscal disclaimer.""" + calls = self._get_canvas_calls() + text = ' '.join(calls) + self.assertIn('art. 319', text) + self.assertIn('227/2015', text) + + def test_proforma_disclaimer(self): + """Proforma PDF must state it's not a fiscal document.""" + proforma = ProformaInvoice.objects.create( + customer=self.customer, currency=self.currency_ron, + number='PRO-EN16931-001', subtotal_cents=5000, tax_cents=1050, + total_cents=6050, status='draft', bill_to_name='Proforma Client', + valid_until=timezone.now() + timezone.timedelta(days=30), + ) + generator = RomanianProformaPDFGenerator(proforma) + calls = [] + original_draw = generator.canvas.drawString + def capture_draw(x, y, text): + calls.append(text) + return original_draw(x, y, text) + generator.canvas.drawString = capture_draw + generator._create_pdf_document() + text = ' '.join(calls) + self.assertIn('nu constituie document fiscal', text) + self.assertTrue(True) def test_document_footer_with_unicode_company_name(self): @@ -1059,7 +1268,11 @@ def test_company_info_called_multiple_times(self, mock_get_company_info): 'city': 'Test City', 'country': 'Test Country', 'cui': 'RO12345678', - 'email': 'test@company.ro' + 'email': 'test@company.ro', + 'registration_number': 'J40/1234/2020', + 'bank_name': 'Test Bank', + 'bank_account': 'RO00TEST0000000000', + 'phone': '+40 700 000 000', } generator = RomanianInvoicePDFGenerator(self.invoice) diff --git a/services/platform/tests/billing/test_pdf_generators_comprehensive.py b/services/platform/tests/billing/test_pdf_generators_comprehensive.py index df2696c2..cba533b8 100644 --- a/services/platform/tests/billing/test_pdf_generators_comprehensive.py +++ b/services/platform/tests/billing/test_pdf_generators_comprehensive.py @@ -324,7 +324,7 @@ def test_base_generator_render_table_data(self) -> None: generator._render_table_data(400.0) # Mock table_y position # Should set font and draw line item data - mock_font.assert_called_with("Helvetica", 9) + mock_font.assert_any_call("Helvetica", 9) mock_draw.assert_called() # Check that line data is included in draw calls @@ -381,7 +381,7 @@ def test_base_generator_get_total_label_default(self) -> None: label = generator._get_total_label() self.assertIn('{amount}', label) - self.assertIn('RON', label) + self.assertIn('{currency}', label) def test_base_generator_render_status_information_base(self) -> None: """Test _render_status_information base implementation (Line 203-205).""" @@ -438,8 +438,8 @@ def test_invoice_generator_get_legal_disclaimer(self) -> None: disclaimer = generator._get_legal_disclaimer() - # Should mention fiscal invoice and Romanian legislation - self.assertIn('Fiscal', disclaimer) + # Should mention Romanian fiscal legislation + self.assertIn('art. 319', disclaimer) def test_invoice_generator_get_total_label(self) -> None: """Test RomanianInvoicePDFGenerator _get_total_label (Line 242-243).""" @@ -588,8 +588,8 @@ def test_proforma_generator_get_legal_disclaimer(self) -> None: disclaimer = generator._get_legal_disclaimer() - # Should clarify that proforma is not a fiscal invoice - self.assertIn('not a fiscal invoice', disclaimer) + # Should clarify that proforma is not a fiscal document + self.assertIn('nu constituie document fiscal', disclaimer) def test_proforma_generator_render_document_details(self) -> None: """Test RomanianProformaPDFGenerator _render_document_details (Line 308-322)."""