Adding PEPPOL e-invoicing to a 13-year-old ERP with Claude Code
My father-in-law runs a professional lighting import business in Belgium. Single-person operation, long-term customers, running on an OpenERP 7.0 instance I installed for him around 2013. Belgium mandates PEPPOL for B2B e-invoicing starting January 1, 2026. He’s not switching to a new system. So I built it.
Why not just migrate?
The obvious answer to “how do I add PEPPOL to OpenERP 7.0” is “don’t, upgrade to Odoo 17.” I considered it. The problem is that this OpenERP instance has been customized over 12 years for one specific workflow: importing professional lighting fixtures, managing Belgian eco-taxes (RECUPEL, BEBAT), handling credit notes with historical paper-era conventions, printing invoices in a format his customers expect. He knows every button. A migration to modern Odoo means months of retraining, re-customizing, and a real risk of breaking something that currently works perfectly for a single-person business.
The constraint was clear: build compliance INTO the existing system.
The research
On September 18, 2025, I sent Claude a first prompt. In French, because that’s how I think about Belgian accounting:
Mon beau-père va avoir une obligation d’envoyer des factures via le réseau peppol à partir du 1er janvier. Il utilise un vieux openerp 7. Je ne vais évidemment pas migrer vers un odoo récent. Comment est-ce que je pourrais implémenter ça avec le minimum de modifications ? Si il y a un provider belge chez qui je pourrais payer un api access et juste envoyer les factures UBL dessus ça serait l’idéal.
Claude’s deep research came back with a concrete recommendation: SCRADA, a Belgian PEPPOL access point provider with a REST API. The approach: generate UBL 2.1 XML from OpenERP’s invoice data, send it through the provider’s API, handle status tracking and incoming documents. Initial estimate: 1-2 weeks.
Claude also generated a working code skeleton for the OpenERP module, complete with the osv.osv class inheritance and cr/uid/ids/context signatures that OpenERP 7.0 expects. That was the “okay, this is doable” moment. Not a vague “here’s how you’d do it in theory” but actual working Python 2.7 code that respects the framework’s conventions. That’s what greenlit the project.
The sidecar pattern
September 26: first commit. The architecture decision was to NOT try to make modern API libraries work in Python 2.7. OpenERP 7.0 runs on Python 2.7 with osv.osv objects, fields.char, and from urlparse import urljoin. Modern PEPPOL APIs use JWT tokens, OAuth2 flows, and libraries that dropped Python 2 support years ago.
The solution: a sidecar service.
%%{init: {"flowchart": {"defaultRenderer": "elk"}} }%%
flowchart LR
erp["<b>OpenERP 7.0</b><br/><i>Python 2.7</i><br/><br/>Invoice data<br/>Tax computation<br/>UI / workflow<br/>Status tracking"] <-->|HTTP| sidecar["<b>PEPPOL Sidecar</b><br/><i>Python 3, Flask</i><br/><br/>UBL generation<br/>JWT auth<br/>Provider APIs<br/>Document polling<br/>S3 backup"]
sidecar --> acube["ACube<br/>PEPPOL API"]
sidecar --> s3["Scaleway<br/>S3 WORM · 10 yr"]
sidecar --> slack["Slack<br/>Alerts"]
OpenERP handles what it’s good at: invoice data, tax computation, the accounting workflow, the UI. The sidecar handles everything that needs modern Python: UBL XML generation, JWT authentication, API calls, document polling, S3 backup. They talk over localhost HTTP. Clean boundary, no hacks on either side.
The technical deep-dive
UBL generation: where every cent matters
PEPPOL requires UBL 2.1 XML that complies with EN 16931 (European e-invoicing standard) and PEPPOL BIS Billing 3.0. The generator lives in the sidecar at ubl_generator.py: about 580 lines that turn OpenERP invoice data into spec-compliant XML.
The core of it is straightforward: map invoice fields to XML elements with the right namespaces.
ns = {
'': 'urn:oasis:names:specification:ubl:schema:xsd:%s-2' % document_tag,
'cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
'cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'
}
root = ET.Element('{%s}%s' % (ns[''], document_tag))
But the devil is in the element ordering. UBL 2.1 is an XML schema with strict sequencing. BuyerReference must come before AdditionalDocumentReference. PartyTaxScheme must come before Contact. Note must come before DocumentCurrencyCode. Get the order wrong and the document fails schema validation, even if all the data is correct. The comments in the generator read like a minefield map: “MUST come AFTER OrderReference”, “MUST come before Contact per UBL 2.1 schema!”
The same function handles both invoices and credit notes, toggling on a single flag:
is_credit_note = invoice_data.get('creditInvoice', False)
document_tag = 'CreditNote' if is_credit_note else 'Invoice'
line_element_name = 'CreditNoteLine' if is_credit_note else 'InvoiceLine'
quantity_element_name = 'CreditedQuantity' if is_credit_note else 'InvoicedQuantity'
type_code_value = '381' if is_credit_note else '380'
That looks simple, but credit notes need a BillingReference pointing back to the original invoice, a CreditNoteTypeCode instead of InvoiceTypeCode, and no DueDate element. Miss any of these and the PEPPOL network rejects it.
Belgian tax edge cases: RECUPEL, BEBAT, and the rounding problem
This is where most of the development time went. Not the API integration, not the UBL generation, but making 12 years of Belgian accounting data fit a specification designed for clean, structured data.
RECUPEL and BEBAT. These are Belgian eco-contribution taxes on electrical equipment and batteries. A lighting importer deals with them on almost every invoice line. They’re not percentage-based like VAT: they’re fixed amounts per unit (e.g., 0.12 EUR per fixture for recycling). In OpenERP, they’re stored as tax lines with include_base_amount=True, which means the VAT base already includes them. In PEPPOL, they need to be represented as AllowanceCharge elements at the line level, with their own TaxCategory and rate:
for charge in allowance_charges:
charge_el = ET.SubElement(inv_line, '{%s}AllowanceCharge' % cac_ns)
ET.SubElement(charge_el, '{%s}ChargeIndicator' % cbc_ns).text = 'true'
ET.SubElement(charge_el, '{%s}AllowanceChargeReasonCode' % cbc_ns).text = 'AEO'
ET.SubElement(charge_el, '{%s}AllowanceChargeReason' % cbc_ns).text = 'RECUPEL'
ET.SubElement(charge_el, '{%s}Amount' % cbc_ns,
currencyID='EUR').text = _format_amount(charge.get('amount', 0), 2)
The charge codes map to human-readable labels in the sidecar: AEO becomes “RECUPEL ARM.” (packaging recycling), CAV becomes “Bebat” (battery recycling). These aren’t standard PEPPOL codes: they’re Belgian conventions that the receiving systems need to recognize.
The rounding problem. PEPPOL is strict about arithmetic consistency. The TaxAmount in the TaxTotal element must exactly match the sum of all TaxSubtotal amounts, which must be derived from the TaxableAmount at the declared rate. A 1-cent discrepancy and the document gets rejected.
Belgian VAT law (effective January 1, 2026) adds a constraint: accumulate VAT amounts per-line WITHOUT rounding, and round only the total per rate at the end. The sidecar implements this with Decimal arithmetic:
# CRITICAL: Belgian law + PEPPOL standard:
# Accumulate VAT amounts WITHOUT rounding per-line
# Round ONLY the total VAT per rate at the end
for line in invoice_lines:
rate = D(line.get('vatPercentage', 0))
entry = vat_acc.setdefault(rate, {'excl': Decimal('0.00'), 'vat': Decimal('0.00')})
line_total = D(line.get('totalExclVat', 0))
entry['excl'] += line_total
entry['vat'] += (line_total * rate / Decimal('100')) # No .quantize() here
# Round only at the end
for rate in sorted(vat_acc.keys(), reverse=True):
entry = vat_acc[rate]
excl = entry['excl'].quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
vat_amt = entry['vat'].quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
Using Python floats here would silently produce rounding errors on invoices with many lines. Decimal arithmetic is the only way to guarantee the totals match to the cent. I spent more time debugging rounding edge cases than writing the actual API integration.
Unit code normalization. OpenERP stores unit of measure as free text. PEPPOL requires UN/ECE Recommendation 20 codes (C62 for “one”, EA for “each”, KGM for “kilogram”). The sidecar has a normalization table mapping messy legacy values to valid codes:
UNIT_CODE_MAP = {
'1': 'C62', 'UNIT': 'C62', 'UNITS': 'C62', 'PCE': 'C62', 'PCS': 'C62',
'PC': 'C62', 'PIECE': 'C62', 'PIÈCE': 'C62', 'STUKS': 'C62',
'KG': 'KGM', 'KGM': 'KGM', 'KILO': 'KGM',
'M': 'MTR', 'MTR': 'MTR', 'METER': 'MTR', 'METRE': 'MTR',
# ...
}
Unknown codes silently fall back to C62 (pieces) to avoid BR-CL-23 validation failures. The alternative is rejecting the invoice and asking the user to fix their unit of measure in OpenERP, which in a single-person business means the invoice doesn’t get sent.
The Belgian dual registration problem
Belgian companies have two identifiers on the PEPPOL network: a CBE number (Crossroads Bank for Enterprises, scheme 0208) and a VAT number (scheme 9925 with BE prefix). Some of their customers are registered under one, some under the other. Some under both. You need to register as both to receive documents from everyone.
ACube (the API provider) requires separate “legal entities” for each identifier. The registration code creates two:
# Registration 1: CBE format (0208:0870386146)
if register_short:
cbe_identifier = self._build_cbe_identifier(identifier_value)
cbe_result = self._register_single_entity(base_payload, cbe_identifier)
# Registration 2: VAT format (9925:BE0870386146)
if register_vat:
vat_identifier = self._build_vat_identifier(identifier_value)
vat_result = self._register_single_entity(base_payload, vat_identifier)
The fun part is on the receiving side. When the poller picks up an incoming document, it needs to check if the receiver ID matches ANY of our identifiers. And the comparison needs normalization: 0208:0870386146 and 9925:BE0870386146 refer to the same company, but look nothing alike.
def normalize_peppol_id(peppol_id):
"""0208:0870386146 -> 0870386146, 9925:BE0870386146 -> 0870386146"""
if not peppol_id:
return None
parts = peppol_id.split(':', 1)
if len(parts) == 2:
value = parts[1]
if value.startswith('BE'):
value = value[2:]
return value.lower()
return peppol_id.lower()
The poller matches against full IDs, normalized IDs, AND stripped IDs. Three levels of comparison because in practice, PEPPOL metadata is inconsistent: sometimes the scheme prefix is there, sometimes it isn’t, sometimes the BE prefix is included, sometimes not.
Transactional safety: store before you confirm
The incoming document processor has a design constraint that drove most of its architecture: Belgian tax law requires 10 years of invoice retention. If you receive a PEPPOL document and confirm receipt to the provider but then lose the document due to a crash or storage failure, you have a legal compliance problem. A document that legally exists but you can’t produce.
The processor enforces a strict order:
1. Store in OpenERP database (with rollback on failure)
2. Backup to Scaleway S3 (WORM object lock, 10-year retention)
3. Email archive to owner
4. ONLY THEN confirm receipt to PEPPOL provider
If any step fails, the document is NOT confirmed and will appear again on the next poll. The provider thinks it’s undelivered, so it keeps trying. This means you might process the same document twice, so there’s a UNIQUE(transmission_id) SQL constraint backed by PostgreSQL advisory locks:
lock_key = int(hashlib.md5(transmission_id.encode()).hexdigest()[:15], 16) % (2**31 - 1)
cr.execute("SELECT pg_try_advisory_lock(%s)", (lock_key,))
lock_acquired = cr.fetchone()[0]
if not lock_acquired:
_logger.info("Document %s already being processed by another worker", transmission_id)
continue
The S3 backup uses Scaleway’s WORM (Write Once Read Many) object locks in COMPLIANCE mode: once written, neither the user nor the administrator can delete the file for 10 years. The folder structure is incoming/YYYY/MM/ with a metadata JSON alongside each UBL XML and PDF:
s3.put_object_retention(
Bucket=bucket, Key=ubl_key,
Retention={'Mode': 'COMPLIANCE', 'RetainUntilDate': retain_until} # 10 years
)
Triple redundancy (database + S3 WORM + email) means the only way to lose a document is if all three fail simultaneously during the window between receiving and confirming. In practice that’s a power failure during a simultaneous S3 outage and mail server crash. Not impossible, but acceptable.
Cash discount: Belgian VAT Code Article 28
This one arrived after the initial build. Some of my father-in-law’s long-term customers have cash discount agreements (escompte): pay within N days and get a percentage off. Belgian VAT Code Article 28, paragraph 1 specifies that when a cash discount applies, VAT must be calculated on the discounted amount, not the full invoice total.
In PEPPOL, this means adding a document-level AllowanceCharge with reason code 100 (early payment discount) and adjusting the PaymentTerms element. The tricky part: the invoice PDF shows full prices (because the customer might not take the discount), but the PEPPOL XML needs the discounted VAT calculations.
class account_invoice(osv.osv):
_inherit = 'account.invoice'
def _get_cash_discount_note(self, cr, uid, ids, field_name, args, context=None):
result = {}
for invoice in self.browse(cr, uid, ids, context=context):
if invoice.cash_discount_percent and invoice.cash_discount_percent > 0:
discount_amount = round(
invoice.amount_untaxed * invoice.cash_discount_percent / 100.0, 2)
note = u"Escompte de %.2g%% (%.2f €) si paiement sous %d jours." % (
invoice.cash_discount_percent, discount_amount,
invoice.cash_discount_days or 0)
result[invoice.id] = note
This is a separate OpenERP module (peppol_cash_discount, 339 lines) that inherits the base invoice and adds the discount fields. The discount settings live on the partner (customer) record and get copied to each invoice at creation time. Clean separation from the main PEPPOL module.
Tax category mapping
PEPPOL requires mapping each tax to a category code from EN 16931. Belgium has three VAT rates (6%, 12%, 21%) but also intra-EU supplies (code K), exports outside EU (code G), VAT-exempt (code E), and reverse charge / co-contractant (code AE). The sidecar derives the category from the tax rate AND the tax name in OpenERP:
def _get_tax_category_code(vat_percentage, tax_name=''):
if vat_percentage == 0:
name_lower = tax_name.lower() if tax_name else ''
if 'eu' in name_lower and 'export' not in name_lower:
return 'K' # Intra-EU supply
elif 'export' in name_lower or 'hors eu' in name_lower:
return 'G' # Export outside EU
elif 'cocontractant' in name_lower or 'reverse' in name_lower:
return 'AE' # Reverse charge
elif 'exempt' in name_lower or 'exon' in name_lower:
return 'E' # VAT exempt
else:
return 'Z' # Zero-rated (default 0%)
else:
return 'S' # Standard rate (6%, 12%, or 21%)
This is messy but necessary. OpenERP 7.0 doesn’t have structured tax type metadata: the only way to distinguish “intra-EU 0%” from “export 0%” from “exempt 0%” is by parsing the tax name string. The mapping works because the tax names were set up in French 12 years ago and haven’t changed.
The SCRADA contract and the switch to ACube
Working prototype by early October. Invoices going out, UBL generation solid, edge cases from 12 years of historical invoices handled. Then the SCRADA contract arrived.
Non-compete clauses. Employment-style liability terms. Hostile conditions for what’s supposed to be a SaaS API product. Instant no.
I asked Claude to find alternatives. ACube came up: an Italian company with Belgian PEPPOL support, clean REST API, JWT authentication, reasonable terms. November 17: the “Before ACube Api” commit, prepping the abstraction. November 21: “Adding ACube.” 65 files changed, 26,374 insertions.
The key decision was building the provider abstraction DURING the switch, not before. An abstract base class with 10 methods:
class PeppolProvider(object):
__metaclass__ = ABCMeta
@abstractmethod
def authenticate(self): pass
@abstractmethod
def send_invoice(self, ubl_xml, metadata): pass
@abstractmethod
def poll_documents(self, filters=None): pass
@abstractmethod
def confirm_receipt(self, document_id): pass
@abstractmethod
def lookup_participant(self, participant_id, scheme='iso6523-actorid-upis'): pass
@abstractmethod
def get_document_status(self, document_id): pass
# + register_company, get_document, get_supported_features, get_required_config_fields
A factory with auto-discovery:
class ProviderFactory:
_providers = {}
@classmethod
def get_provider(cls, provider_name, config):
provider_class = cls._providers[provider_name.lower()]
provider_instance = provider_class(config)
provider_instance.validate_config()
return provider_instance
# Auto-discover on module load
def auto_discover_providers():
from .scrada_provider import ScradaProvider
ProviderFactory.register_provider('scrada', ScradaProvider)
from .acube_provider import ACubeProvider
ProviderFactory.register_provider('acube', ACubeProvider)
Plus a migration wizard in OpenERP’s UI to switch providers with automatic re-registration.
The reason this only took 4 days (November 17 to November 21): ALL the hard work was already done. The UBL XML generation, the tax handling, the RECUPEL extraction, the credit note matching, the rounding logic: none of that is provider-specific. The UBL is the same regardless of which API delivers it. The ACube switch was purely the transport layer: JWT authentication instead of API keys, different endpoints, different response formats. Claude refactored the SCRADA-specific API code into the abstraction AND implemented the ACube provider in the same pass.
Production surprises
Despite testing on years of historical invoices, the first production week surfaced new edge cases. The commit log tells it:
- November 25-26: The poller was duplicating documents because ACube’s metadata sometimes returns the receiver ID without the scheme prefix. Fixed by adding the three-level normalization described above.
- November 27: All UI labels needed French translation. 12 files changed, 302 insertions, 302 deletions: literally every English string swapped for French.
- November 28: Six commits in one day. The email fallback wasn’t attaching both UBL and PDF. A partner name overwrite bug was clobbering manual corrections. A UBL generation hotfix for an edge case in the
__init__.pyimport order. ACube’s download filter was returning already-processed documents. - December 2: Cash discount (escompte) surfaced as a requirement from a long-term customer. 18 files, 1,020 insertions: a full new module.
- December 22: Last fix: the incoming document parser was choking on credit notes from a specific supplier’s format.
The gap between “it works on test data” and “it works on every real invoice” is always wider than you think. Belgian accounting has enough special cases that you can’t anticipate them all from historical data alone.
What got built
About 24,700 lines across both OpenERP modules, plus the sidecar service. Active development from September 26 to December 22, with maybe 2-3 weeks of focused work spread across that period.
- Multi-provider architecture: SCRADA as legacy, ACube as default. Factory pattern, abstract base, migration wizard.
- Bidirectional: outgoing invoices/credit notes AND incoming document processing, with polling every 5 minutes.
- Triple redundancy: database + Scaleway S3 WORM locks (10-year retention) + email archive.
- Belgian dual registration: CBE scheme (0208) and VAT scheme (9925), with normalized matching.
- Email fallback: when a recipient isn’t on PEPPOL, automatic fallback to emailing UBL + PDF.
- Graceful degradation: Slack notifications, New Relic monitoring, S3 backup all fail independently without blocking the invoice flow.
- Full French UI: every field, status, and error message in French.
What I learned about AI and legacy code
The initial research prompt made the whole project possible. Without Claude identifying the API-based approach and the first provider, I would have spent days just figuring out that PEPPOL access points exist and how to evaluate them. When SCRADA fell through on contract terms, Claude found ACube AND helped abstract the architecture in one pass.
Legacy codebases turn out to be surprisingly good territory for AI-assisted development. The patterns are stable. The constraints are well-understood. There’s no moving target of framework updates or breaking changes. OpenERP 7.0’s ORM is simple: osv.osv, fields.char, cr/uid/ids/context everywhere. Claude picked up the conventions from the first file it read and maintained them consistently across 24,000+ lines.
But the human steering was non-negotiable. Belgian tax rules aren’t in any training set. SCRADA’s contract red flags required reading a legal document and making a business judgment call. “This RECUPEL tax line is wrong because the eco-contribution rate changed in 2019” is domain knowledge that no model has. The cash discount VAT rule from Article 28 of the Belgian VAT Code needed me to actually read the law and explain it to Claude.
The split was roughly 80/20. Claude writes 80% of the code: the module structure, provider implementations, API clients, UBL generation, UI views, translations. The remaining 20% is where the real work lives: tax edge cases, production data cleansing, deciding which architectural trade-offs to make, and testing against real invoices where a 1-cent rounding difference means a PEPPOL rejection.
Even with extensive testing on 12 years of historical invoices, the first week of production surfaced issues that only real-world usage reveals. That last 5% is the hardest, and it’s the part where you need a human who understands the business.