Document Preparation
Guidelines for creating high-quality Peppol documents that pass validation.
Invoice Creation Checklist​
Required Elements​
Every Peppol BIS 3.0 invoice must include:
| Element | Description | Example |
|---|---|---|
ID | Unique invoice number | INV-2024-00001 |
IssueDate | Invoice date | 2024-01-15 |
InvoiceTypeCode | Document type | 380 (invoice) |
DocumentCurrencyCode | Currency | EUR, USD, GBP |
AccountingSupplierParty | Seller details | Name, address, VAT |
AccountingCustomerParty | Buyer details | Name, address, ID |
TaxTotal | Tax summary | Amount, breakdown |
LegalMonetaryTotal | Totals | Subtotal, tax, payable |
InvoiceLine | Line items | At least one line |
Pre-Send Validation​
def prepare_invoice(data: dict) -> str:
"""Build and validate invoice before sending."""
# 1. Build UBL XML
invoice_xml = build_ubl_invoice(data)
# 2. Local validation (fast)
local_errors = validate_locally(invoice_xml)
if local_errors:
raise ValidationError(local_errors)
# 3. API validation (comprehensive)
response = requests.post(
"https://app.goroute.ai/peppol-api/api/v1/validate",
headers={"X-API-Key": api_key},
data=invoice_xml
)
result = response.json()
if not result["valid"]:
raise ValidationError(result["errors"])
# 4. Check for warnings
if result.get("warnings"):
log_warnings(result["warnings"])
return invoice_xml
Data Quality​
Clean Text Fields​
import re
def clean_text(text: str, max_length: int = 100) -> str:
"""Sanitize text for XML."""
if not text:
return ""
# Remove control characters
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
# Trim whitespace
text = text.strip()
# Truncate if needed
if len(text) > max_length:
text = text[:max_length-3] + "..."
return text
Validate Identifiers​
def validate_peppol_id(scheme: str, identifier: str) -> bool:
"""Validate Peppol participant identifier."""
validators = {
"0106": validate_kvk, # Netherlands
"0190": validate_oin, # Netherlands gov
"0192": validate_no_org, # Norway
"0195": validate_uen, # Singapore
"0204": validate_leitweg, # Germany gov
"0208": validate_cbe, # Belgium
"9906": validate_it_vat, # Italy
"9930": validate_de_vat, # Germany
}
validator = validators.get(scheme)
if validator:
return validator(identifier)
# Generic validation for unknown schemes
return bool(identifier and len(identifier) <= 50)
Amount Handling​
from decimal import Decimal, ROUND_HALF_UP
def format_amount(value: float, currency: str = "EUR") -> str:
"""Format amount for Peppol invoice."""
# Convert to Decimal for precision
d = Decimal(str(value))
# Round to 2 decimal places
rounded = d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return str(rounded)
def calculate_line_totals(lines: list) -> dict:
"""Calculate invoice totals from lines."""
line_extension = Decimal("0")
tax_exclusive = Decimal("0")
tax_total = Decimal("0")
for line in lines:
quantity = Decimal(str(line["quantity"]))
price = Decimal(str(line["unit_price"]))
tax_rate = Decimal(str(line.get("tax_rate", 0)))
line_amount = (quantity * price).quantize(Decimal("0.01"))
line_tax = (line_amount * tax_rate / 100).quantize(Decimal("0.01"))
line["amount"] = str(line_amount)
line["tax_amount"] = str(line_tax)
line_extension += line_amount
tax_total += line_tax
tax_exclusive = line_extension
tax_inclusive = tax_exclusive + tax_total
return {
"line_extension": str(line_extension),
"tax_exclusive": str(tax_exclusive),
"tax_total": str(tax_total),
"tax_inclusive": str(tax_inclusive),
"payable": str(tax_inclusive)
}
Common Pitfalls​
1. Mismatched Totals​
Problem: Line totals don't sum to invoice total.
# Wrong - floating point errors
total = 10.10 + 10.20 + 10.30 # Might not equal 30.60!
# Right - use Decimal
from decimal import Decimal
total = Decimal("10.10") + Decimal("10.20") + Decimal("10.30")
2. Invalid Characters​
Problem: Special characters break XML.
# Wrong
name = "Johnson & Johnson" # & is invalid in XML
# Right
from xml.sax.saxutils import escape
name = escape("Johnson & Johnson") # "Johnson & Johnson"
3. Wrong Date Format​
Problem: Incorrect date format.
# Wrong
issue_date = "15/01/2024" # European format
issue_date = "01-15-2024" # US format
# Right - ISO 8601
issue_date = "2024-01-15" # YYYY-MM-DD
4. Missing Buyer Reference​
Problem: B2G invoices rejected without buyer reference.
def build_invoice(data: dict) -> str:
invoice = InvoiceBuilder()
# Always include for B2G
if data.get("is_government") or data.get("buyer_country") in ["DE", "FR"]:
if not data.get("buyer_reference"):
raise ValueError("Buyer reference required for B2G invoices")
invoice.set_buyer_reference(data["buyer_reference"])
return invoice.build()
5. Currency Mismatch​
Problem: Line currency differs from document currency.
# All amounts must use document currency
document_currency = "EUR"
for line in lines:
line_currency = line.get("currency", document_currency)
if line_currency != document_currency:
raise ValueError(
f"Line currency {line_currency} must match "
f"document currency {document_currency}"
)
UBL Structure​
Invoice Template​
<?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">
<!-- REQUIRED: Peppol BIS 3.0 Customization -->
<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>
<!-- REQUIRED: Invoice identification -->
<cbc:ID>INVOICE-NUMBER</cbc:ID>
<cbc:IssueDate>YYYY-MM-DD</cbc:IssueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<!-- CONDITIONAL: Required for B2G -->
<cbc:BuyerReference>REFERENCE</cbc:BuyerReference>
<!-- REQUIRED: Seller -->
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="SCHEME">IDENTIFIER</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>Seller Name</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Street</cbc:StreetName>
<cbc:CityName>City</cbc:CityName>
<cbc:PostalZone>12345</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>XX</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>VAT-NUMBER</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Legal Name</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<!-- REQUIRED: Buyer -->
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="SCHEME">IDENTIFIER</cbc:EndpointID>
<cac:PartyName>
<cbc:Name>Buyer Name</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Street</cbc:StreetName>
<cbc:CityName>City</cbc:CityName>
<cbc:PostalZone>12345</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>XX</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Legal Name</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<!-- RECOMMENDED: Payment terms -->
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>IBAN</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<!-- REQUIRED: Tax totals -->
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">0.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">0.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">0.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<!-- REQUIRED: Monetary totals -->
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">0.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">0.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">0.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">0.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<!-- REQUIRED: At least one line -->
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">0.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Item Description</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">0.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
Unit Codes​
Common UN/ECE Rec 20 unit codes:
| Code | Description |
|---|---|
| C62 | Each (piece) |
| HUR | Hour |
| DAY | Day |
| MON | Month |
| KGM | Kilogram |
| MTR | Meter |
| LTR | Liter |
| PCE | Piece |
| EA | Each |