mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-17 18:38:46 -05:00
70510a6622
Quotes (#583): - Add requires_approval, approval_level, and can_be_sent; wire create form - Migrations 145 (approval columns) and 146 (quote_items.position) - Order Quote.items by position; set positions on create/edit/duplicate/API - Fix view template approval branch (not_required); add web regression test Invoices / PEPPOL: - Use the same Factur-X embed and PDF/A-3 normalization for export and email attachments; Associated File Data + text/xml metadata - CII/UBL validators, pdfa3, zugferd, and invoice_pdf_postprocess helper - Bundle compact sRGB ICC (app/resources/icc/); INVOICE_SRGB_ICC_PATH override - Package data in setup.py; extend PEPPOL_EINVOICING.md and tests
278 lines
11 KiB
Python
278 lines
11 KiB
Python
"""Tests for invoice validators (UBL, CII, veraPDF)."""
|
|
import pytest
|
|
|
|
from app.utils.invoice_validators import (
|
|
validate_ubl_wellformed,
|
|
validate_ubl_peppol_bis3,
|
|
validate_cii_wellformed,
|
|
validate_cii_en16931,
|
|
)
|
|
|
|
|
|
# ---- UBL well-formedness ----
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_ubl_wellformed_accepts_valid_invoice():
|
|
ubl = '<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>INV-001</ID></Invoice>'
|
|
passed, msgs = validate_ubl_wellformed(ubl)
|
|
assert passed is True
|
|
assert msgs == []
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_ubl_wellformed_rejects_invalid_xml():
|
|
passed, msgs = validate_ubl_wellformed("<bad>")
|
|
assert passed is False
|
|
assert len(msgs) >= 1
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_ubl_wellformed_rejects_non_invoice_root():
|
|
ubl = '<?xml version="1.0"?><NotInvoice xmlns="urn:test"><x/></NotInvoice>'
|
|
passed, msgs = validate_ubl_wellformed(ubl)
|
|
assert passed is False
|
|
assert "Invoice" in msgs[0]
|
|
|
|
|
|
# ---- UBL Peppol BIS 3.0 structural validation ----
|
|
|
|
def _minimal_peppol_ubl() -> str:
|
|
return """<?xml version="1.0" encoding="UTF-8"?>
|
|
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
|
|
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
|
|
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
|
|
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
|
|
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
|
|
<cbc:ID>INV-001</cbc:ID>
|
|
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
|
|
<cbc:IssueDate>2024-01-15</cbc:IssueDate>
|
|
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
|
|
<cbc:BuyerReference>PO-12345</cbc:BuyerReference>
|
|
<cac:AccountingSupplierParty>
|
|
<cac:Party>
|
|
<cbc:EndpointID schemeID="9915">BE0123456789</cbc:EndpointID>
|
|
<cac:PartyName><cbc:Name>Seller</cbc:Name></cac:PartyName>
|
|
</cac:Party>
|
|
</cac:AccountingSupplierParty>
|
|
<cac:AccountingCustomerParty>
|
|
<cac:Party>
|
|
<cbc:EndpointID schemeID="0088">1234567890123</cbc:EndpointID>
|
|
<cac:PartyName><cbc:Name>Buyer</cbc:Name></cac:PartyName>
|
|
</cac:Party>
|
|
</cac:AccountingCustomerParty>
|
|
<cac:TaxTotal>
|
|
<cbc:TaxAmount currencyID="EUR">0.00</cbc:TaxAmount>
|
|
</cac:TaxTotal>
|
|
<cac:LegalMonetaryTotal>
|
|
<cbc:PayableAmount currencyID="EUR">100.00</cbc:PayableAmount>
|
|
</cac:LegalMonetaryTotal>
|
|
<cac:InvoiceLine>
|
|
<cbc:ID>1</cbc:ID>
|
|
<cbc:InvoicedQuantity unitCode="C62">1.00</cbc:InvoicedQuantity>
|
|
<cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>
|
|
<cac:Item><cbc:Name>Service</cbc:Name></cac:Item>
|
|
<cac:Price><cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount></cac:Price>
|
|
</cac:InvoiceLine>
|
|
</Invoice>"""
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_ubl_peppol_bis3_accepts_valid():
|
|
passed, issues = validate_ubl_peppol_bis3(_minimal_peppol_ubl())
|
|
assert passed is True, f"Unexpected issues: {issues}"
|
|
assert issues == []
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_ubl_peppol_bis3_detects_missing_buyer_reference():
|
|
ubl = _minimal_peppol_ubl().replace(
|
|
"<cbc:BuyerReference>PO-12345</cbc:BuyerReference>", ""
|
|
)
|
|
passed, issues = validate_ubl_peppol_bis3(ubl)
|
|
assert passed is False
|
|
assert any("BuyerReference" in i for i in issues)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_ubl_peppol_bis3_detects_missing_endpoint():
|
|
ubl = _minimal_peppol_ubl().replace(
|
|
'<cbc:EndpointID schemeID="9915">BE0123456789</cbc:EndpointID>',
|
|
"",
|
|
1,
|
|
)
|
|
passed, issues = validate_ubl_peppol_bis3(ubl)
|
|
assert passed is False
|
|
assert any("EndpointID" in i for i in issues)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_ubl_peppol_bis3_detects_missing_lines():
|
|
ubl = _minimal_peppol_ubl()
|
|
# Remove InvoiceLine section
|
|
start = ubl.index("<cac:InvoiceLine>")
|
|
end = ubl.index("</cac:InvoiceLine>") + len("</cac:InvoiceLine>")
|
|
ubl = ubl[:start] + ubl[end:]
|
|
passed, issues = validate_ubl_peppol_bis3(ubl)
|
|
assert passed is False
|
|
assert any("InvoiceLine" in i for i in issues)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_ubl_peppol_bis3_detects_missing_unitcode():
|
|
ubl = _minimal_peppol_ubl().replace('unitCode="C62"', "")
|
|
passed, issues = validate_ubl_peppol_bis3(ubl)
|
|
assert passed is False
|
|
assert any("unitCode" in i for i in issues)
|
|
|
|
|
|
# ---- CII well-formedness ----
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_cii_wellformed_accepts_valid():
|
|
cii = '<?xml version="1.0"?><rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"><x/></rsm:CrossIndustryInvoice>'
|
|
passed, msgs = validate_cii_wellformed(cii)
|
|
assert passed is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_cii_wellformed_rejects_ubl():
|
|
ubl = '<?xml version="1.0"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"><ID>1</ID></Invoice>'
|
|
passed, msgs = validate_cii_wellformed(ubl)
|
|
assert passed is False
|
|
assert "CrossIndustryInvoice" in msgs[0]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_cii_wellformed_rejects_invalid_xml():
|
|
passed, msgs = validate_cii_wellformed("<not valid")
|
|
assert passed is False
|
|
|
|
|
|
# ---- CII EN 16931 structural validation ----
|
|
|
|
def _minimal_cii_en16931() -> str:
|
|
return """<?xml version="1.0" encoding="UTF-8"?>
|
|
<rsm:CrossIndustryInvoice
|
|
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
|
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
|
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
|
|
<rsm:ExchangedDocumentContext>
|
|
<ram:GuidelineSpecifiedDocumentContextParameter>
|
|
<ram:ID>urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:en16931</ram:ID>
|
|
</ram:GuidelineSpecifiedDocumentContextParameter>
|
|
</rsm:ExchangedDocumentContext>
|
|
<rsm:ExchangedDocument>
|
|
<ram:ID>INV-001</ram:ID>
|
|
<ram:TypeCode>380</ram:TypeCode>
|
|
<ram:IssueDateTime>
|
|
<udt:DateTimeString format="102">20240115</udt:DateTimeString>
|
|
</ram:IssueDateTime>
|
|
</rsm:ExchangedDocument>
|
|
<rsm:SupplyChainTradeTransaction>
|
|
<ram:ApplicableHeaderTradeAgreement>
|
|
<ram:SellerTradeParty>
|
|
<ram:Name>Seller Company</ram:Name>
|
|
</ram:SellerTradeParty>
|
|
<ram:BuyerTradeParty>
|
|
<ram:Name>Buyer Company</ram:Name>
|
|
</ram:BuyerTradeParty>
|
|
</ram:ApplicableHeaderTradeAgreement>
|
|
<ram:ApplicableHeaderTradeDelivery/>
|
|
<ram:ApplicableHeaderTradeSettlement>
|
|
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
|
|
<ram:ApplicableTradeTax>
|
|
<ram:CalculatedAmount currencyID="EUR">0.00</ram:CalculatedAmount>
|
|
<ram:TypeCode>VAT</ram:TypeCode>
|
|
<ram:BasisAmount currencyID="EUR">100.00</ram:BasisAmount>
|
|
<ram:CategoryCode>Z</ram:CategoryCode>
|
|
<ram:RateApplicablePercent>0.00</ram:RateApplicablePercent>
|
|
<ram:ExemptionReason>Not subject to VAT</ram:ExemptionReason>
|
|
<ram:ExemptionReasonCode>VATEX-EU-O</ram:ExemptionReasonCode>
|
|
</ram:ApplicableTradeTax>
|
|
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
|
<ram:LineTotalAmount currencyID="EUR">100.00</ram:LineTotalAmount>
|
|
<ram:TaxBasisTotalAmount currencyID="EUR">100.00</ram:TaxBasisTotalAmount>
|
|
<ram:TaxTotalAmount currencyID="EUR">0.00</ram:TaxTotalAmount>
|
|
<ram:GrandTotalAmount currencyID="EUR">100.00</ram:GrandTotalAmount>
|
|
<ram:DuePayableAmount currencyID="EUR">100.00</ram:DuePayableAmount>
|
|
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
|
</ram:ApplicableHeaderTradeSettlement>
|
|
<ram:IncludedSupplyChainTradeLineItem>
|
|
<ram:AssociatedDocumentLineDocument>
|
|
<ram:LineID>1</ram:LineID>
|
|
</ram:AssociatedDocumentLineDocument>
|
|
<ram:SpecifiedTradeProduct>
|
|
<ram:Name>Service</ram:Name>
|
|
</ram:SpecifiedTradeProduct>
|
|
<ram:SpecifiedLineTradeAgreement>
|
|
<ram:NetPriceProductTradePrice>
|
|
<ram:ChargeAmount currencyID="EUR">100.00</ram:ChargeAmount>
|
|
</ram:NetPriceProductTradePrice>
|
|
</ram:SpecifiedLineTradeAgreement>
|
|
<ram:SpecifiedLineTradeDelivery>
|
|
<ram:BilledQuantity unitCode="C62">1.00</ram:BilledQuantity>
|
|
</ram:SpecifiedLineTradeDelivery>
|
|
<ram:SpecifiedLineTradeSettlement>
|
|
<ram:ApplicableTradeTax>
|
|
<ram:TypeCode>VAT</ram:TypeCode>
|
|
<ram:CategoryCode>Z</ram:CategoryCode>
|
|
<ram:RateApplicablePercent>0.00</ram:RateApplicablePercent>
|
|
<ram:ExemptionReason>Not subject to VAT</ram:ExemptionReason>
|
|
<ram:ExemptionReasonCode>VATEX-EU-O</ram:ExemptionReasonCode>
|
|
</ram:ApplicableTradeTax>
|
|
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
|
<ram:LineTotalAmount currencyID="EUR">100.00</ram:LineTotalAmount>
|
|
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
|
</ram:SpecifiedLineTradeSettlement>
|
|
</ram:IncludedSupplyChainTradeLineItem>
|
|
</rsm:SupplyChainTradeTransaction>
|
|
</rsm:CrossIndustryInvoice>"""
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_cii_en16931_accepts_valid():
|
|
passed, issues = validate_cii_en16931(_minimal_cii_en16931())
|
|
assert passed is True, f"Unexpected issues: {issues}"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_cii_en16931_detects_missing_seller():
|
|
cii = _minimal_cii_en16931().replace(
|
|
"<ram:SellerTradeParty>\n <ram:Name>Seller Company</ram:Name>\n </ram:SellerTradeParty>",
|
|
"",
|
|
)
|
|
passed, issues = validate_cii_en16931(cii)
|
|
assert passed is False
|
|
assert any("Seller" in i for i in issues)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_cii_en16931_detects_missing_document_id():
|
|
cii = _minimal_cii_en16931().replace("<ram:ID>INV-001</ram:ID>", "")
|
|
passed, issues = validate_cii_en16931(cii)
|
|
assert passed is False
|
|
assert any("ID" in i or "Invoice number" in i for i in issues)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_cii_en16931_detects_missing_line_items():
|
|
cii = _minimal_cii_en16931()
|
|
start = cii.index("<ram:IncludedSupplyChainTradeLineItem>")
|
|
end = cii.index("</ram:IncludedSupplyChainTradeLineItem>") + len(
|
|
"</ram:IncludedSupplyChainTradeLineItem>"
|
|
)
|
|
cii = cii[:start] + cii[end:]
|
|
passed, issues = validate_cii_en16931(cii)
|
|
assert passed is False
|
|
assert any("LineItem" in i or "line" in i.lower() for i in issues)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_validate_cii_en16931_detects_missing_grand_total():
|
|
cii = _minimal_cii_en16931().replace(
|
|
'<ram:GrandTotalAmount currencyID="EUR">100.00</ram:GrandTotalAmount>', ""
|
|
)
|
|
passed, issues = validate_cii_en16931(cii)
|
|
assert passed is False
|
|
assert any("GrandTotal" in i for i in issues)
|