feat: match inbound invoices to suppliers

This commit is contained in:
Julien Denizot
2026-06-19 12:52:50 +00:00
parent 11f7c0804e
commit 320dd80d0e
7 changed files with 1400 additions and 10 deletions
@@ -1,2 +1,189 @@
// Copyright (c) 2026, Enuxia and contributors
// For license information, please see license.txt
frappe.ui.form.on(
"Electronic Invoice Exchange",
{
refresh(frm) {
if (
frm.is_new()
|| frm.doc.direction !== "Inbound"
) {
return;
}
set_review_indicator(frm);
if (!frm.doc.supplier) {
frm.add_custom_button(
__("Rapprochement automatique"),
() => refresh_supplier_match(frm),
__("Réception fournisseur")
);
}
frm.add_custom_button(
__("Choisir un fournisseur"),
() => open_supplier_dialog(frm),
__("Réception fournisseur")
);
if (frm.doc.supplier) {
frm.add_custom_button(
__("Dissocier le fournisseur"),
() => clear_supplier_match(frm),
__("Réception fournisseur")
);
}
},
}
);
function set_review_indicator(frm) {
const indicators = {
"To Review": ["À examiner", "orange"],
"Supplier Matched": [
"Fournisseur rapproché",
"blue",
],
"Ready for Import": [
"Prête pour limport",
"green",
],
Imported: ["Importée", "green"],
Ignored: ["Ignorée", "gray"],
Error: ["Erreur", "red"],
};
const indicator = (
indicators[frm.doc.review_status]
|| [frm.doc.review_status || "À examiner", "gray"]
);
frm.page.set_indicator(
__(indicator[0]),
indicator[1]
);
}
function refresh_supplier_match(frm) {
frappe.call({
method:
"enuxia_einvoice.inbound_import."
+ "refresh_supplier_match",
args: {
exchange_name: frm.doc.name,
},
freeze: true,
freeze_message: __(
"Recherche du fournisseur…"
),
callback(response) {
const result = response.message || {};
if (result.matched) {
frappe.show_alert({
message: __(
"Fournisseur rapproché : {0}",
[result.supplier]
),
indicator: "green",
});
} else {
frappe.msgprint({
title: __("Aucun rapprochement"),
message: result.message,
indicator: "orange",
});
}
frm.reload_doc();
},
});
}
function open_supplier_dialog(frm) {
const dialog = new frappe.ui.Dialog({
title: __("Choisir un fournisseur"),
fields: [
{
fieldname: "supplier",
label: __("Fournisseur"),
fieldtype: "Link",
options: "Supplier",
reqd: 1,
default: frm.doc.supplier || "",
},
],
primary_action_label: __("Associer"),
primary_action(values) {
frappe.call({
method:
"enuxia_einvoice.inbound_import."
+ "assign_supplier",
args: {
exchange_name: frm.doc.name,
supplier: values.supplier,
},
freeze: true,
callback(response) {
const result = response.message || {};
dialog.hide();
if (result.vat_matches) {
frappe.show_alert({
message: __(
"Fournisseur associé et TVA confirmée."
),
indicator: "green",
});
} else {
frappe.msgprint({
title: __(
"Fournisseur associé manuellement"
),
message: __(
"Le fournisseur a été associé, "
+ "mais son numéro de TVA ne correspond "
+ "pas exactement à celui de la facture."
),
indicator: "orange",
});
}
frm.reload_doc();
},
});
},
});
dialog.show();
}
function clear_supplier_match(frm) {
frappe.confirm(
__(
"Supprimer le rapprochement avec le fournisseur {0} ?",
[frm.doc.supplier]
),
() => {
frappe.call({
method:
"enuxia_einvoice.inbound_import."
+ "clear_supplier_match",
args: {
exchange_name: frm.doc.name,
},
freeze: true,
callback() {
frappe.show_alert({
message: __(
"Rapprochement fournisseur supprimé."
),
indicator: "blue",
});
frm.reload_doc();
},
});
}
);
}
@@ -27,6 +27,24 @@
"payload_hash",
"sent_at",
"last_sync_at",
"inbound_section",
"review_status",
"supplier",
"supplier_match_method",
"remote_invoice_number",
"received_at",
"issue_date",
"payment_due_date",
"currency",
"inbound_amounts_column",
"net_total",
"vat_total",
"grand_total",
"amount_due",
"inbound_files_section",
"semantic_payload_file",
"semantic_payload_hash",
"supplier_snapshot",
"events_section",
"events",
"audit_section",
@@ -85,15 +103,13 @@
"label": "Type de document source",
"fieldtype": "Link",
"options": "DocType",
"default": "Sales Invoice",
"reqd": 1
"default": "Sales Invoice"
},
{
"fieldname": "source_document",
"label": "Document source",
"fieldtype": "Dynamic Link",
"options": "source_doctype",
"reqd": 1,
"in_list_view": 1
},
{
@@ -101,15 +117,13 @@
"label": "Type de tiers",
"fieldtype": "Link",
"options": "DocType",
"default": "Customer",
"reqd": 1
"default": "Customer"
},
{
"fieldname": "party",
"label": "Tiers",
"fieldtype": "Dynamic Link",
"options": "party_type",
"reqd": 1
"options": "party_type"
},
{
"fieldname": "transport_section",
@@ -223,6 +237,125 @@
"read_only": 1,
"cannot_add_rows": 1,
"cannot_delete_rows": 1
},
{
"fieldname": "inbound_section",
"label": "Réception fournisseur",
"fieldtype": "Section Break",
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "review_status",
"label": "Statut dexamen",
"fieldtype": "Select",
"options": "To Review\nSupplier Matched\nReady for Import\nImported\nIgnored\nError",
"read_only": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "supplier",
"label": "Fournisseur rapproché",
"fieldtype": "Link",
"options": "Supplier",
"read_only": 1
},
{
"fieldname": "supplier_match_method",
"label": "Méthode de rapprochement",
"fieldtype": "Data",
"read_only": 1
},
{
"fieldname": "remote_invoice_number",
"label": "Numéro de facture fournisseur",
"fieldtype": "Data",
"read_only": 1,
"in_list_view": 1
},
{
"fieldname": "received_at",
"label": "Reçue le",
"fieldtype": "Datetime",
"read_only": 1
},
{
"fieldname": "issue_date",
"label": "Date de facture",
"fieldtype": "Date",
"read_only": 1
},
{
"fieldname": "payment_due_date",
"label": "Date d’échéance",
"fieldtype": "Date",
"read_only": 1
},
{
"fieldname": "currency",
"label": "Devise",
"fieldtype": "Link",
"options": "Currency",
"read_only": 1
},
{
"fieldname": "inbound_amounts_column",
"fieldtype": "Column Break"
},
{
"fieldname": "net_total",
"label": "Total HT",
"fieldtype": "Currency",
"options": "currency",
"read_only": 1
},
{
"fieldname": "vat_total",
"label": "Total TVA",
"fieldtype": "Currency",
"options": "currency",
"read_only": 1
},
{
"fieldname": "grand_total",
"label": "Total TTC",
"fieldtype": "Currency",
"options": "currency",
"read_only": 1
},
{
"fieldname": "amount_due",
"label": "Net à payer",
"fieldtype": "Currency",
"options": "currency",
"read_only": 1
},
{
"fieldname": "inbound_files_section",
"label": "Documents reçus",
"fieldtype": "Section Break",
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "semantic_payload_file",
"label": "Données EN16931",
"fieldtype": "Attach",
"read_only": 1
},
{
"fieldname": "semantic_payload_hash",
"label": "Empreinte EN16931",
"fieldtype": "Data",
"read_only": 1,
"hidden": 1
},
{
"fieldname": "supplier_snapshot",
"label": "Instantané du fournisseur",
"fieldtype": "Code",
"options": "JSON",
"read_only": 1
}
],
"grid_page_length": 50,
@@ -38,7 +38,20 @@ SUPPORTED_STATUSES = {
class ElectronicInvoiceExchange(Document):
"""Trace daudit dun document de facturation électronique."""
"""Trace daudit dun document électronique."""
def before_validate(self) -> None:
if self.direction == "Inbound":
if not self.review_status:
self.review_status = "To Review"
return
# Les statuts dexamen et de rapprochement fournisseur
# ne concernent jamais les échanges sortants.
self.review_status = None
self.supplier = None
self.supplier_match_method = None
def before_insert(self) -> None:
if not self.external_id:
@@ -49,6 +62,7 @@ class ElectronicInvoiceExchange(Document):
self._validate_provider()
self._validate_environment()
self._validate_status()
self._validate_directional_requirements()
self._validate_source_document()
def _validate_direction(self) -> None:
@@ -75,6 +89,32 @@ class ElectronicInvoiceExchange(Document):
_("Statut interne d’échange invalide.")
)
def _validate_directional_requirements(self) -> None:
if self.direction == "Outbound":
if not self.source_doctype or not self.source_document:
frappe.throw(
_(
"Un échange sortant doit être lié "
"à un document source."
)
)
if not self.party_type or not self.party:
frappe.throw(
_(
"Un échange sortant doit être lié "
"à un tiers."
)
)
if self.direction == "Inbound" and not self.remote_id:
frappe.throw(
_(
"Un échange entrant doit posséder "
"un identifiant SuperPDP."
)
)
def _validate_source_document(self) -> None:
if not self.source_doctype or not self.source_document:
return
+813
View File
@@ -0,0 +1,813 @@
# Copyright (c) 2026, Enuxia and contributors
# For license information, please see license.txt
from __future__ import annotations
import json
import re
from datetime import timezone
from hashlib import sha256
from typing import Any
import frappe
from frappe import _
from frappe.utils import get_datetime, now_datetime
from frappe.utils.file_manager import save_file
from enuxia_einvoice.inbound import (
summarize_incoming_invoice,
)
from enuxia_einvoice.sending import (
build_event_row,
map_provider_status,
normalize_provider_events,
)
from enuxia_einvoice.validation import (
get_authenticated_client,
)
class InboundImportError(ValueError):
"""Le document entrant ne peut pas être archivé."""
def normalize_remote_datetime(
value: Any,
):
if not value:
return None
result = get_datetime(value)
if result.tzinfo is not None:
result = (
result
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
return result
def detect_original_document(
content: bytes,
) -> dict[str, str]:
"""Identifier le format du document original."""
if not isinstance(content, bytes) or not content.strip():
raise InboundImportError(
"Le document original reçu est vide."
)
stripped = content.lstrip()
if stripped.startswith(b"%PDF"):
return {
"document_format": "Factur-X",
"extension": "pdf",
"content_type": "application/pdf",
}
if stripped.startswith(b"<"):
header = stripped[:2000].decode(
"utf-8",
errors="ignore",
)
if (
"Invoice-2" in header
or "<Invoice" in header
):
return {
"document_format": "UBL",
"extension": "xml",
"content_type": "application/xml",
}
if "CrossIndustryInvoice" in header:
return {
"document_format": "CII",
"extension": "xml",
"content_type": "application/xml",
}
raise InboundImportError(
"Le format du document original nest pas reconnu."
)
def find_supplier_by_vat(
vat_identifier: str,
) -> tuple[str | None, str]:
"""Rapprocher uniquement sur une TVA unique."""
vat_identifier = str(
vat_identifier or ""
).strip()
normalized_vat = normalize_tax_identifier(
vat_identifier
)
if not normalized_vat:
return None, ""
supplier_meta = frappe.get_meta(
"Supplier"
)
if not supplier_meta.has_field("tax_id"):
return None, ""
exact_suppliers = frappe.get_all(
"Supplier",
filters={
"tax_id": vat_identifier,
},
pluck="name",
limit=2,
)
if len(exact_suppliers) == 1:
return exact_suppliers[0], "Exact VAT ID"
if len(exact_suppliers) > 1:
return None, ""
candidates = frappe.get_all(
"Supplier",
filters=[
[
"tax_id",
"is",
"set",
]
],
fields=[
"name",
"tax_id",
],
limit=5000,
)
normalized_matches = [
row["name"]
for row in candidates
if normalize_tax_identifier(
row.get("tax_id")
)
== normalized_vat
]
if len(normalized_matches) != 1:
return None, ""
return (
normalized_matches[0],
"Normalized VAT ID",
)
def _extract_vat_total(summary: dict[str, Any]) -> Any:
value = summary.get("total_vat_amount")
if isinstance(value, dict):
return value.get("value") or 0
return value or 0
def _safe_filename(value: str) -> str:
result = re.sub(
r"[^A-Za-z0-9._-]+",
"-",
str(value or ""),
).strip("-")
return result or "invoice"
def build_inbound_exchange_values(
remote_invoice: dict[str, Any],
*,
company: str,
environment: str,
supplier: str | None,
match_method: str,
) -> dict[str, Any]:
summary = summarize_incoming_invoice(
remote_invoice
)
provider_status = str(
summary.get("provider_status") or ""
)
internal_status = map_provider_status(
provider_status,
current_status="Delivered",
)
seller = summary.get("seller") or {}
buyer = summary.get("buyer") or {}
routing_snapshot = {
"seller": {
"scheme": seller.get(
"electronic_address_scheme"
),
"value": seller.get(
"electronic_address_value"
),
},
"buyer": {
"scheme": buyer.get(
"electronic_address_scheme"
),
"value": buyer.get(
"electronic_address_value"
),
},
}
return {
"doctype": "Electronic Invoice Exchange",
"direction": "Inbound",
"company": company,
"provider": "SuperPDP",
"environment": environment,
"external_id": (
f"superpdp-in-{summary['remote_id']}"
),
"remote_id": str(
summary["remote_id"]
),
"document_format": "EN16931 JSON",
"internal_status": internal_status,
"provider_status": provider_status,
"party_type": (
"Supplier" if supplier else ""
),
"party": supplier or "",
"supplier": supplier or "",
"supplier_match_method": match_method,
"review_status": (
"Supplier Matched"
if supplier
else "To Review"
),
"remote_invoice_number": summary.get(
"invoice_number"
),
"received_at": normalize_remote_datetime(
summary.get("created_at")
),
"issue_date": summary.get(
"issue_date"
),
"payment_due_date": summary.get(
"payment_due_date"
),
"currency": summary.get(
"currency_code"
),
"net_total": summary.get(
"total_without_vat"
) or 0,
"vat_total": _extract_vat_total(
summary
),
"grand_total": summary.get(
"total_with_vat"
) or 0,
"amount_due": summary.get(
"amount_due_for_payment"
) or 0,
"routing_snapshot": json.dumps(
routing_snapshot,
ensure_ascii=False,
sort_keys=True,
indent=2,
),
"supplier_snapshot": json.dumps(
seller,
ensure_ascii=False,
sort_keys=True,
indent=2,
),
"provider_response": json.dumps(
{
"id": remote_invoice.get("id"),
"company_id": remote_invoice.get(
"company_id"
),
"direction": remote_invoice.get(
"direction"
),
"created_at": remote_invoice.get(
"created_at"
),
},
ensure_ascii=False,
sort_keys=True,
indent=2,
),
}
def _assert_invoice_belongs_to_account(
remote_invoice: dict[str, Any],
account,
) -> None:
expected_company_id = str(
account.remote_company_id or ""
).strip()
actual_company_id = str(
remote_invoice.get("company_id") or ""
).strip()
if (
expected_company_id
and actual_company_id
and expected_company_id != actual_company_id
):
frappe.throw(
_(
"La facture entrante nappartient pas "
"au compte SuperPDP configuré."
)
)
expected_number = str(
account.remote_company_number or ""
).strip()
buyer = (
remote_invoice.get("en_invoice", {})
.get("buyer", {})
)
actual_number = str(
(
buyer.get(
"legal_registration_identifier"
)
or {}
).get("value")
or ""
).strip()
if (
expected_number
and actual_number
and expected_number != actual_number
):
frappe.throw(
_(
"Lacheteur de la facture ne correspond pas "
"à la société ERPNext configurée."
)
)
def normalize_tax_identifier(
value: Any,
) -> str:
"""Normaliser un identifiant fiscal pour la comparaison."""
return re.sub(
r"[^A-Z0-9]",
"",
str(value or "").upper(),
)
def _get_inbound_exchange(
exchange_name: str,
):
exchange = frappe.get_doc(
"Electronic Invoice Exchange",
exchange_name,
)
exchange.check_permission("write")
if exchange.direction != "Inbound":
frappe.throw(
_(
"Cette opération est réservée "
"aux échanges entrants."
)
)
return exchange
def _get_seller_snapshot(
exchange,
) -> dict[str, Any]:
try:
value = json.loads(
exchange.supplier_snapshot or "{}"
)
except (
TypeError,
ValueError,
json.JSONDecodeError,
):
value = {}
return value if isinstance(value, dict) else {}
def _apply_supplier_match(
exchange,
*,
supplier: str,
match_method: str,
) -> None:
exchange.supplier = supplier
exchange.party_type = "Supplier"
exchange.party = supplier
exchange.supplier_match_method = match_method
exchange.review_status = "Supplier Matched"
exchange.save(
ignore_permissions=True
)
@frappe.whitelist()
def refresh_supplier_match(
exchange_name: str,
) -> dict[str, Any]:
"""Relancer le rapprochement automatique par TVA."""
exchange = _get_inbound_exchange(
exchange_name
)
seller = _get_seller_snapshot(
exchange
)
vat_identifier = str(
seller.get("vat_identifier") or ""
).strip()
supplier, match_method = (
find_supplier_by_vat(
vat_identifier
)
)
if not supplier:
return {
"ok": True,
"matched": False,
"exchange": exchange.name,
"vat_identifier": vat_identifier,
"message": _(
"Aucun fournisseur unique ne correspond "
"au numéro de TVA reçu."
),
}
_apply_supplier_match(
exchange,
supplier=supplier,
match_method=match_method,
)
return {
"ok": True,
"matched": True,
"exchange": exchange.name,
"supplier": supplier,
"match_method": match_method,
"vat_identifier": vat_identifier,
"message": _(
"Le fournisseur a été rapproché automatiquement."
),
}
@frappe.whitelist()
def assign_supplier(
exchange_name: str,
supplier: str,
) -> dict[str, Any]:
"""Associer manuellement un fournisseur existant."""
exchange = _get_inbound_exchange(
exchange_name
)
if not frappe.db.exists(
"Supplier",
supplier,
):
frappe.throw(
_("Le fournisseur sélectionné nexiste pas.")
)
supplier_tax_id = str(
frappe.db.get_value(
"Supplier",
supplier,
"tax_id",
)
or ""
).strip()
seller = _get_seller_snapshot(
exchange
)
received_tax_id = str(
seller.get("vat_identifier") or ""
).strip()
vat_matches = bool(
normalize_tax_identifier(
supplier_tax_id
)
and normalize_tax_identifier(
supplier_tax_id
)
== normalize_tax_identifier(
received_tax_id
)
)
match_method = (
"Manual selection VAT confirmed"
if vat_matches
else "Manual selection"
)
_apply_supplier_match(
exchange,
supplier=supplier,
match_method=match_method,
)
return {
"ok": True,
"matched": True,
"exchange": exchange.name,
"supplier": supplier,
"supplier_tax_id": supplier_tax_id,
"received_tax_id": received_tax_id,
"vat_matches": vat_matches,
"match_method": match_method,
"message": _(
"Le fournisseur a été associé à la facture entrante."
),
}
@frappe.whitelist()
def clear_supplier_match(
exchange_name: str,
) -> dict[str, Any]:
"""Supprimer un rapprochement sans supprimer le fournisseur."""
exchange = _get_inbound_exchange(
exchange_name
)
exchange.supplier = ""
exchange.party_type = ""
exchange.party = ""
exchange.supplier_match_method = ""
exchange.review_status = "To Review"
exchange.save(
ignore_permissions=True
)
return {
"ok": True,
"exchange": exchange.name,
"message": _(
"Le rapprochement fournisseur a été supprimé."
),
}
@frappe.whitelist()
def import_incoming_invoice(
company: str,
invoice_id: int,
) -> dict[str, Any]:
"""Archiver une facture reçue sans créer de Purchase Invoice."""
frappe.only_for("System Manager")
remote_id = str(
int(invoice_id)
)
existing = frappe.db.get_value(
"Electronic Invoice Exchange",
{
"direction": "Inbound",
"provider": "SuperPDP",
"remote_id": remote_id,
},
"name",
)
if existing:
exchange = frappe.get_doc(
"Electronic Invoice Exchange",
existing,
)
return {
"ok": True,
"created": False,
"exchange": exchange.name,
"remote_id": remote_id,
"review_status": exchange.review_status,
"supplier": exchange.supplier,
"message": _(
"Cette facture entrante est déjà archivée."
),
}
account = frappe.get_doc(
"Electronic Invoicing Account",
company,
)
client, access_token = (
get_authenticated_client(company)
)
remote_invoice = client.get_invoice(
access_token,
invoice_id=int(remote_id),
invoice_format="en16931",
)
if remote_invoice.get("direction") != "in":
frappe.throw(
_("La facture demandée nest pas entrante.")
)
_assert_invoice_belongs_to_account(
remote_invoice,
account,
)
seller = (
remote_invoice.get("en_invoice", {})
.get("seller", {})
)
supplier, match_method = (
find_supplier_by_vat(
seller.get("vat_identifier") or ""
)
)
values = build_inbound_exchange_values(
remote_invoice,
company=company,
environment=account.environment,
supplier=supplier,
match_method=match_method,
)
# Cette valeur dépend du fuseau configuré dans Frappe.
# Elle est donc ajoutée uniquement dans le contexte réel du site.
values["last_sync_at"] = now_datetime()
exchange = frappe.get_doc(values)
exchange.insert(
ignore_permissions=True
)
semantic_content = json.dumps(
remote_invoice,
ensure_ascii=False,
sort_keys=True,
indent=2,
default=str,
).encode("utf-8")
semantic_hash = sha256(
semantic_content
).hexdigest()
invoice_number = (
values.get("remote_invoice_number")
or remote_id
)
safe_number = _safe_filename(
invoice_number
)
semantic_file = save_file(
(
f"{safe_number}-"
f"{semantic_hash[:12]}-en16931.json"
),
semantic_content,
"Electronic Invoice Exchange",
exchange.name,
is_private=1,
)
original = client.get_invoice_file(
access_token,
invoice_id=int(remote_id),
invoice_format="original",
)
original_content = original["content"]
detected = detect_original_document(
original_content
)
original_hash = sha256(
original_content
).hexdigest()
original_file = save_file(
(
f"{safe_number}-"
f"{original_hash[:12]}-original."
f"{detected['extension']}"
),
original_content,
"Electronic Invoice Exchange",
exchange.name,
is_private=1,
)
exchange.semantic_payload_file = (
semantic_file.file_url
)
exchange.semantic_payload_hash = (
semantic_hash
)
exchange.payload_file = (
original_file.file_url
)
exchange.payload_hash = (
original_hash
)
exchange.document_format = (
detected["document_format"]
)
for event in normalize_provider_events(
{
"data": (
remote_invoice.get("events")
or []
)
}
):
exchange.append(
"events",
build_event_row(event),
)
exchange.save(
ignore_permissions=True
)
return {
"ok": True,
"created": True,
"exchange": exchange.name,
"remote_id": remote_id,
"invoice_number": invoice_number,
"review_status": exchange.review_status,
"supplier": exchange.supplier,
"supplier_match_method": (
exchange.supplier_match_method
),
"semantic_file": (
exchange.semantic_payload_file
),
"original_file": exchange.payload_file,
"document_format": (
exchange.document_format
),
"event_count": len(
exchange.get("events") or []
),
"message": _(
"La facture entrante a été archivée "
"sans création comptable."
),
}
@@ -357,6 +357,48 @@ class SuperPDPClient:
return self._read_response(response)
def get_invoice_file(
self,
access_token: str,
*,
invoice_id: int,
invoice_format: str = "original",
) -> dict[str, Any]:
"""Télécharger le contenu binaire d'une facture."""
response = self.session.get(
(
f"{self.base_url}/v1.beta/invoices/"
f"{invoice_id}"
),
headers=self._authorization_headers(
access_token
),
params={
"format": invoice_format,
},
timeout=self.timeout,
)
if not response.ok:
self._read_response(response)
raise RuntimeError(
"Le téléchargement SuperPDP a échoué."
)
content_type = str(
response.headers.get(
"Content-Type",
"application/octet-stream",
)
).split(";", 1)[0].strip().lower()
return {
"content": bytes(response.content),
"content_type": content_type,
}
def create_invoice(
self,
access_token: str,
@@ -636,5 +636,50 @@ class TestSuperPDPClient(unittest.TestCase):
)
def test_get_invoice_original_file(self) -> None:
xml = (
b'<?xml version="1.0"?>'
b'<Invoice/>'
)
response = Mock()
response.ok = True
response.status_code = 200
response.content = xml
response.headers = {
"Content-Type": "application/xml; charset=utf-8",
}
self.session.get.return_value = response
result = self.client.get_invoice_file(
"secret-access-token",
invoice_id=79712,
)
self.assertEqual(
result["content"],
xml,
)
self.assertEqual(
result["content_type"],
"application/xml",
)
self.session.get.assert_called_once_with(
(
"https://api.superpdp.tech/"
"v1.beta/invoices/79712"
),
headers={
"Authorization": "Bearer secret-access-token",
},
params={
"format": "original",
},
timeout=(5, 30),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,130 @@
from __future__ import annotations
import unittest
from enuxia_einvoice.inbound_import import (
build_inbound_exchange_values,
detect_original_document,
normalize_tax_identifier,
)
class TestInboundInvoiceImport(unittest.TestCase):
def remote_invoice(self):
return {
"id": 79712,
"company_id": 4920,
"direction": "in",
"created_at": "2026-06-19T12:35:14Z",
"events": [
{
"id": 263426,
"status_code": "fr:202",
}
],
"en_invoice": {
"number": "F20260619_123507_609",
"issue_date": "2025-06-30",
"payment_due_date": "2025-07-30",
"currency_code": "EUR",
"seller": {
"name": "Tricatel",
"vat_identifier": "FR15000000001",
"electronic_address": {
"scheme": "0225",
"value": "315143296_4919",
},
},
"buyer": {
"name": "Burger Queen",
"electronic_address": {
"scheme": "0225",
"value": "315143296_4920",
},
},
"lines": [],
"totals": {
"total_without_vat": "1560.46",
"total_vat_amount": {
"value": "303.33",
"currency_code": "EUR",
},
"total_with_vat": "1863.79",
"amount_due_for_payment": "1863.79",
},
},
}
def test_detect_ubl(self) -> None:
result = detect_original_document(
(
b'<?xml version="1.0"?>'
b'<Invoice xmlns="urn:oasis:names:'
b'specification:ubl:schema:xsd:Invoice-2"/>'
)
)
self.assertEqual(
result["document_format"],
"UBL",
)
def test_detect_factur_x(self) -> None:
result = detect_original_document(
b"%PDF-1.7 test"
)
self.assertEqual(
result["document_format"],
"Factur-X",
)
def test_build_exchange_values(self) -> None:
values = build_inbound_exchange_values(
self.remote_invoice(),
company="Test",
environment="Sandbox",
supplier=None,
match_method="",
)
self.assertEqual(
values["direction"],
"Inbound",
)
self.assertEqual(
values["remote_id"],
"79712",
)
self.assertEqual(
values["review_status"],
"To Review",
)
self.assertEqual(
values["grand_total"],
"1863.79",
)
self.assertEqual(
values["internal_status"],
"Delivered",
)
def test_normalize_tax_identifier(self) -> None:
self.assertEqual(
normalize_tax_identifier(
" fr 15 000 000 001 "
),
"FR15000000001",
)
self.assertEqual(
normalize_tax_identifier(
"FR15-000-000-001"
),
"FR15000000001",
)
if __name__ == "__main__":
unittest.main()