UblEx

Parse and generate UBL (Universal Business Language) documents in Elixir with full round-trip support.

Peppol BIS Billing 3.0 compliantUBL 2.1EN16931

Features

Installation

Add ubl_ex to your list of dependencies in mix.exs:

def deps do
  [
    {:ubl_ex, "~> 0.7.0"}
  ]
end

Quick Start

Parsing a UBL Document

# Simple parse with automatic schema detection
{:ok, parsed} = UblEx.parse(xml_content)

# Or be explicit about the schema
{:ok, parsed} = UblEx.parse_xml(xml_content, :ubl_peppol)

# Access the data
IO.puts("Document type: #{parsed.type}")        # :invoice, :credit, or :application_response
IO.puts("Invoice number: #{parsed.number}")
IO.puts("Supplier: #{parsed.supplier.name}")
IO.puts("Customer: #{parsed.customer.name}")
IO.puts("Total: #{length(parsed.details)} line items")

Generating a UBL Invoice

document_data = %{
  type: :invoice,
  number: "F2024001",
  date: ~D[2024-01-15],
  expires: ~D[2024-02-14],

  supplier: %{
    endpoint_id: "0797948229",
    scheme: "0208",
    name: "My Company",
    street: "Main Street 123",
    city: "Brussels",
    zipcode: "1000",
    country: "BE",
    vat: "BE0797948229",
    email: "invoice@mycompany.com"
  },

  customer: %{
    endpoint_id: "0012345625",
    scheme: "0208",
    name: "Customer Corp",
    vat: "BE0012345625",
    street: "Customer Street",
    housenumber: "45",
    city: "Antwerp",
    zipcode: "2000",
    country: "BE"
  },

  details: [
    %{
      name: "Consulting Services - January 2024",
      quantity: Decimal.new("40.0"),
      price: Decimal.new("75.00"),
      vat: Decimal.new("21.00"),
      discount: Decimal.new("0.00")
    }
  ]
}

# Generate the XML
xml = UblEx.generate(document_data)

Generating a Credit Note

credit_note_data = %{
  type: :credit,
  number: "C2024001",
  date: ~D[2024-01-20],

  # Reference original invoices
  billing_references: ["F2024001", "F2024002"],

  supplier: %{...},
  customer: %{...},
  details: [...]
}

xml = UblEx.generate(credit_note_data)

Generating an Application Response

Application responses are used to acknowledge receipt and processing status of invoices:

response_data = %{
  type: :application_response,
  id: "RESPONSE-001",
  date: ~D[2025-06-02],
  response_code: "AB",  # AB = Acknowledged, RE = Rejected
  document_reference: "INV-123",
  sender: %{
    endpoint_id: "0797948229",
    scheme: "0208",
    name: "My Company"
  },
  receiver: %{
    endpoint_id: "0844125969",
    scheme: "0208",
    name: "Supplier Inc"
  },
  status_reason: "Invoice approved",  # Optional
  note: "Payment scheduled"  # Optional
}

xml = UblEx.generate(response_data)

Working with Attachments

# Include PDF attachments (e.g., signed invoice)
document_data = %{
  type: :invoice,
  number: "F2024001",
  # ... other fields ...

  attachments: [
    %{
      filename: "F2024001.pdf",
      mime_type: "application/pdf",
      data: Base.encode64(pdf_binary)
    },
    %{
      filename: "terms.pdf",
      mime_type: "application/pdf",
      data: Base.encode64(terms_pdf)
    }
  ]
}

xml = UblEx.generate(document_data)

# Parse documents with attachments
{:ok, parsed} = UblEx.parse(xml)
parsed.attachments
# => [%{filename: "F2024001.pdf", mime_type: "application/pdf", data: "base64..."}]

Generating SBDH-Wrapped Documents

For Peppol network transmission, wrap documents in a Standard Business Document Header (SBDH):

# Same document data as before
document_data = %{
  type: :invoice,
  number: "F2024001",
  date: ~D[2024-01-15],
  # ... all other fields ...
}

# Generate with SBDH wrapper for Peppol network
sbdh_xml = UblEx.generate_with_sbdh(document_data)

# The SBDH includes routing information automatically derived from:
# - Supplier endpoint_id and scheme -> SBDH Sender
# - Customer VAT (if no endpoint_id) -> SBDH Receiver
# - Document type and customization -> SBDH Business Scope

# Parse SBDH-wrapped documents (automatically unwraps)
{:ok, parsed} = UblEx.parse(sbdh_xml)
# Returns the same data structure as parsing unwrapped UBL

Validating UBL Documents

UblEx includes an optional validator that validates your generated UBL documents against official Peppol BIS Billing 3.0 rules using the free peppol.helger.com validation service.

Note: This feature requires the optional req dependency. Add it to your mix.exs:

def deps do
  [
    {:ubl_ex, "~> 0.6.0"},
    {:req, "~> 0.5.0"}  # Required for validation
  ]
end

Validating an Invoice

# Generate an invoice
document_data = %{type: :invoice, number: "F2024001", ...}
xml = UblEx.generate(document_data)

# Validate against Peppol BIS Billing 3.0
case UblEx.Validator.validate(xml, :invoice) do
  {:ok, result} ->
    IO.puts("✓ Valid Peppol invoice!")
    if result.warnings != [] do
      IO.puts("Warnings: #{inspect(result.warnings)}")
    end

  {:error, %{success: false, errors: errors}} ->
    IO.puts("✗ Invalid invoice:")
    Enum.each(errors, fn error -> IO.puts("  - #{error}") end)
end

Validating a Credit Note

xml = UblEx.generate(%{type: :credit, ...})
UblEx.Validator.validate(xml, :credit)

Validation Options

# Custom timeout (default: 30 seconds)
UblEx.Validator.validate(xml, :invoice, timeout: 60_000)

# Override VESID (validation executor set ID)
UblEx.Validator.validate(xml, :invoice, vesid: "eu.peppol.bis3:invoice:3.13.0")

Understanding Validation Results

The validator returns:

{:ok, result} = UblEx.Validator.validate(xml, :invoice)
result.success  # true/false
result.errors   # List of error messages
result.warnings # List of warning messages

Important Notes:

Document Structure

Invoice and Credit Note Structure

%{
  # Document metadata
  type: :invoice | :credit,
  number: "F2024001",
  date: ~D[2024-01-15],
  expires: ~D[2024-02-14],           # Invoices only
  order_reference: "PO-12345",
  billing_references: ["F001"],       # Credit notes only
  payment_id: "+++000/2024/00186+++", # Optional: Belgian structured payment reference

  # Supplier information
  supplier: %{
    endpoint_id: "0797948229",
    scheme: "0208",                   # Optional: inferred from country if not set
    name: "Company Name",
    street: "Street 123",
    city: "City",
    zipcode: "1000",
    country: "BE",
    vat: "BE0797948229",
    email: "invoice@company.com",
    iban: "BE68539007547034"           # Required for payment means
  },

  # Customer information
  customer: %{
    endpoint_id: "0012345625",
    # scheme: "0208",                 # Optional: inferred from country if not set
    name: "Customer Name",
    vat: "BE0012345625",
    street: "Customer Street",
    housenumber: "45",
    city: "City",
    zipcode: "2000",
    country: "BE"
  },

  # Line items
  details: [
    %{
      name: "Service or product description",
      quantity: Decimal.new("1.00"),
      price: Decimal.new("100.00"),
      vat: Decimal.new("21.00"),      # VAT percentage
      discount: Decimal.new("0.00"),  # Discount percentage
      tax_category: :standard,        # Optional: defaults to :standard for non-zero VAT, :zero_rated for 0%
      tax_exemption_reason_code: "vatex-eu-ic",  # Required for E, G, K, AE tax categories
      tax_exemption_reason: "Exemption reason"   # Required for E, G, K, AE tax categories
    }
  ],

  # Optional attachments
  attachments: [
    %{
      filename: "invoice.pdf",
      mime_type: "application/pdf",
      data: "base64encoded..."
    }
  ]
}

Application Response Structure

%{
  # Document metadata
  type: :application_response,
  id: "RESPONSE-001",
  date: ~D[2025-06-02],
  response_code: "AB",                # AB = Acknowledged, RE = Rejected, AP = Accepted with errors, CA = Conditionally accepted
  document_reference: "INV-123",      # The invoice/credit note being acknowledged
  status_reason: "Optional reason",
  note: "Optional note",

  # Sender (the party sending the response)
  sender: %{
    endpoint_id: "0797948229",
    scheme: "0208",
    name: "Company Name"
  },

  # Receiver (the party receiving the response)
  receiver: %{
    endpoint_id: "0844125969",
    scheme: "0208",
    name: "Supplier Name"
  }
}

API Reference

Parsing

UblEx.parse(xml_content)

Parse UBL XML with automatic schema detection. This is the recommended way to parse documents.

Returns {:ok, parsed_data} or {:error, reason}.

UblEx.parse_xml(xml_content, schema_id)

Parse XML with a specific schema (:ubl_peppol). Use this when you know the schema in advance.

Returns {:ok, parsed_data} or {:error, reason}.

Generation

UblEx.generate(document_data)

Generate XML based on the :type field in the data (:invoice, :credit, or :application_response).

UblEx.generate_with_sbdh(document_data)

Generate XML wrapped in SBDH (Standard Business Document Header) for Peppol network transmission.

Tax Categories

UblEx supports all Peppol BIS 3.0 tax categories via the tax_category field on line items:

Atom Peppol Code Use Case
:standard S Standard rated VAT (default for 6/12/21%)
:zero_rated Z Zero rated goods (default for 0% VAT)
:exempt E Exempt from tax
:reverse_charge AE Domestic reverse charge
:intra_community K EU cross-border B2B (intra-community supply)
:export G Export outside EU
:outside_scope O Services outside scope of tax

EU Intra-Community Transactions

For B2B transactions between EU countries where the customer is liable for VAT:

document_data = %{
  type: :invoice,
  number: "F2024001",
  # ...
  details: [
    %{
      name: "Consulting Services",
      quantity: Decimal.new("1.00"),
      price: Decimal.new("1000.00"),
      vat: Decimal.new("0.00"),
      discount: Decimal.new("0.00"),
      tax_category: :intra_community,  # Generates tax category "K" in UBL
      tax_exemption_reason_code: "vatex-eu-ic",
      tax_exemption_reason: "Intra-community supply - Article 138 Directive 2006/112/EC"
    }
  ]
}

Mixed Tax Categories

You can mix different tax categories in the same invoice:

details: [
  %{
    name: "Standard Service",
    quantity: Decimal.new("1.00"),
    price: Decimal.new("500.00"),
    vat: Decimal.new("21.00"),
    discount: Decimal.new("0.00")
    # tax_category defaults to :standard, no exemption fields needed
  },
  %{
    name: "EU Cross-Border Service",
    quantity: Decimal.new("1.00"),
    price: Decimal.new("1000.00"),
    vat: Decimal.new("0.00"),
    discount: Decimal.new("0.00"),
    tax_category: :intra_community,
    tax_exemption_reason_code: "vatex-eu-ic",
    tax_exemption_reason: "Intra-community supply"
  },
  %{
    name: "Export Service",
    quantity: Decimal.new("1.00"),
    price: Decimal.new("750.00"),
    vat: Decimal.new("0.00"),
    discount: Decimal.new("0.00"),
    tax_category: :export,
    tax_exemption_reason_code: "vatex-eu-g",
    tax_exemption_reason: "Export outside EU"
  }
]

Tax Exemption Fields

According to Peppol BIS 3.0 validation rules (BR-O-11 through BR-O-14), when using tax categories :exempt (E), :export (G), :intra_community (K), or :reverse_charge (AE), you must provide tax exemption information:

The VATEX code list follows the format vatex-{country}-{code}. Common codes include:

For the complete list of VATEX codes, see the official Peppol VATEX code list.

Example: Intra-Community Supply

details: [
  %{
    name: "EU Cross-Border Service",
    quantity: Decimal.new("1.00"),
    price: Decimal.new("1000.00"),
    vat: Decimal.new("0.00"),
    discount: Decimal.new("0.00"),
    tax_category: :intra_community,
    tax_exemption_reason_code: "vatex-eu-ic",
    tax_exemption_reason: "Intra-community supply - Article 138 Directive 2006/112/EC"
  }
]

Example: Domestic Reverse Charge

details: [
  %{
    name: "Construction Services",
    quantity: Decimal.new("1.00"),
    price: Decimal.new("5000.00"),
    vat: Decimal.new("0.00"),
    discount: Decimal.new("0.00"),
    tax_category: :reverse_charge,
    tax_exemption_reason_code: "vatex-eu-ae",
    tax_exemption_reason: "Reverse charge - VAT to be paid by the customer"
  }
]

Example: Export Outside EU

details: [
  %{
    name: "Exported Goods",
    quantity: Decimal.new("1.00"),
    price: Decimal.new("2000.00"),
    vat: Decimal.new("0.00"),
    discount: Decimal.new("0.00"),
    tax_category: :export,
    tax_exemption_reason_code: "vatex-eu-g",
    tax_exemption_reason: "Export outside EU - Article 146 Directive 2006/112/EC"
  }
]

Peppol Scheme IDs

The scheme field on supplier and customer is optional. If not provided, it is automatically inferred from the country field using the following mappings:

Country Scheme Description
AT 9915 Austria - VAT
BE 0208 Belgium - KBO/BCE
BG 9926 Bulgaria - VAT
CY 9928 Cyprus - VAT
CZ 9929 Czech Republic - VAT
DE 0204 Germany - Leitweg-ID
DK 0096 Denmark - CVR
EE 9931 Estonia - VAT
ES 9920 Spain - VAT
FI 0037 Finland - LY-tunnus
FR 0009 France - SIRET
GR 9933 Greece - VAT
HR 9934 Croatia - VAT
HU 9910 Hungary - VAT
IE 9935 Ireland - VAT
IT 0201 Italy - Codice Fiscale
LT 9937 Lithuania - VAT
LU 9938 Luxembourg - VAT
LV 9939 Latvia - VAT
MT 9943 Malta - VAT
NL 0106 Netherlands - KvK
PL 9945 Poland - VAT
PT 9946 Portugal - VAT
RO 9947 Romania - VAT
SE 0007 Sweden - Organisationsnummer
SI 9949 Slovenia - VAT
SK 9950 Slovakia - VAT

For countries not in this list, the fallback is 0088 (EAN/GLN - international).

You can always override the inferred scheme by explicitly setting the scheme field on the party.

Real-World Usage

Basic Invoice Processing

defmodule MyApp.Invoices do
  def import_ubl_invoice(xml_file_path) do
    with {:ok, xml} <- File.read(xml_file_path),
         {:ok, parsed} <- UblEx.parse(xml) do

      # Save to your database
      %Invoice{}
      |> Invoice.changeset(%{
        number: parsed.number,
        date: parsed.date,
        supplier_name: parsed.supplier.name,
        customer_name: parsed.customer.name,
        total: calculate_total(parsed.details)
      })
      |> Repo.insert()
    end
  end

  defp calculate_total(details) do
    Enum.reduce(details, Decimal.new(0), fn item, acc ->
      line_total = Decimal.mult(item.quantity, item.price)
      Decimal.add(acc, line_total)
    end)
  end
end

Generate Invoice from Database

defmodule MyApp.Invoices do
  def generate_ubl_xml(invoice_id) do
    invoice = Repo.get!(Invoice, invoice_id) |> Repo.preload([:customer, :supplier, :line_items])

    document_data = %{
      type: :invoice,
      number: invoice.number,
      date: invoice.date,
      expires: invoice.due_date,

      supplier: %{
        endpoint_id: invoice.supplier.endpoint_id,
        scheme: invoice.supplier.scheme,
        name: invoice.supplier.name,
        street: invoice.supplier.street,
        city: invoice.supplier.city,
        zipcode: invoice.supplier.zipcode,
        country: invoice.supplier.country,
        vat: invoice.supplier.vat,
        email: invoice.supplier.email
      },

      customer: %{
        endpoint_id: invoice.customer.endpoint_id,
        scheme: invoice.customer.scheme,
        name: invoice.customer.name,
        vat: invoice.customer.vat,
        street: invoice.customer.street,
        housenumber: invoice.customer.housenumber,
        city: invoice.customer.city,
        zipcode: invoice.customer.zipcode,
        country: invoice.customer.country
      },

      details: Enum.map(invoice.line_items, fn item ->
        base = %{
          name: item.description,
          quantity: item.quantity,
          price: item.unit_price,
          vat: item.vat_rate,
          discount: item.discount_percentage
        }
        if item.tax_category, do: Map.put(base, :tax_category, item.tax_category), else: base
      end)
    }

    UblEx.generate(document_data)
  end
end

Compliance

This library generates UBL documents compliant with:

Changelog

See CHANGELOG.md for version history.

License

MIT License - see LICENSE for details.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.