"""Facturo RD - Electronic Billing & Fiscal Emission API Client. Usable both inside Odoo (e.g. l10n_do_ecf_connect) and standalone (scripts, microservices). Provides strongly typed dataclasses for requests and responses, with dual dict/object access. Python Version Compatibility: Compatible: Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13+ Minimum Required: Python 3.7 Incompatible: Python 2.x, Python <= 3.6 Technical Compatibility Rationale: - Requires standard library `dataclasses` (PEP 557, added in Python 3.7). - Requires `from __future__ import annotations` (PEP 563, added in Python 3.7). - Avoids Python 3.8+ features (no walrus operator `:=`, no positional-only parameters `/`). - Avoids Python 3.10+ union syntax (`|`), using `typing.Union` and `typing.Optional` instead. - Avoids Python 3.11+ `typing.Self`, using `TypeVar("T", bound="BaseDTO")` and `Type[T]` instead. - Fully compatible with the execution environments of Odoo 14, 15, 16, 17, and 18. """ from __future__ import annotations import json import logging from dataclasses import asdict, dataclass, field, fields from enum import Enum from typing import Any, Dict, List, Optional, Type, TypeVar, Union try: import requests from requests.exceptions import RequestException, Timeout except ImportError as exc: raise ImportError( "The 'requests' package is required by FacturoClient. Install it with: pip install requests" ) from exc # Seamless integration with Odoo or standalone execution try: from odoo.exceptions import UserError except ImportError: class UserError(Exception): """Fallback UserError exception when running outside of Odoo runtime.""" pass _logger = logging.getLogger(__name__) T = TypeVar("T", bound="BaseDTO") # --------------------------------------------------------------------------------------------------- # DGII Fiscal Enumerations & Constants # --------------------------------------------------------------------------------------------------- class DGIIEnv(str, Enum): """Supported DGII environments.""" TESTECF = "testecf" CERTECF = "certecf" ECF = "ecf" DGIIEnvType = Union[DGIIEnv, str] class ECFType(int, Enum): """Standard DGII Electronic Fiscal Document Types (e-CF).""" E31_CREDITO_FISCAL = 31 E32_CONSUMO = 32 E33_NOTA_DEBITO = 33 E34_NOTA_CREDITO = 34 E41_COMPRAS = 41 E43_GASTOS_MENORES = 43 E44_REGIMENES_ESPECIALES = 44 E45_GUBERNAMENTAL = 45 E46_EXPORTACIONES = 46 E47_PAGOS_EXTERIOR = 47 ECFTypeValue = Union[ECFType, int] # --------------------------------------------------------------------------------------------------- # Base Data Transfer Object (DTO) # --------------------------------------------------------------------------------------------------- @dataclass class BaseDTO: """Base dataclass providing dict-like access, serialization, and robust deserialization.""" def to_dict(self) -> Dict[str, Any]: """Convert dataclass to clean Python dictionary.""" return asdict(self) def __getitem__(self, key: str) -> Any: try: return getattr(self, key) except AttributeError as err: raise KeyError(key) from err def get(self, key: str, default: Any = None) -> Any: return getattr(self, key, default) def __contains__(self, key: str) -> bool: return hasattr(self, key) @classmethod def from_dict(cls: Type[T], data: Dict[str, Any]) -> T: """Instantiate dataclass filtering out any unknown extra fields.""" if not isinstance(data, dict): return data # type: ignore[return-value] valid_fields = {f.name for f in fields(cls)} filtered = {k: v for k, v in data.items() if k in valid_fields} return cls(**filtered) # --------------------------------------------------------------------------------------------------- # DGII ANECF v1.0 Cancellation Dataclasses # --------------------------------------------------------------------------------------------------- @dataclass class SequenceRange(BaseDTO): """Represents a range of consecutive e-NCF sequences [from_encf, to_encf].""" from_encf: str to_encf: str @classmethod def single(cls, encf: str) -> "SequenceRange": """Convenience constructor for a single e-NCF number.""" clean = encf.strip() return cls(from_encf=clean, to_encf=clean) @dataclass class CancellationDetail(BaseDTO): """Cancellations grouped by e-CF type (e.g. 31, 32, 34).""" ecf_type: ECFTypeValue sequence_ranges: List[SequenceRange] line_number: Optional[int] = None def to_dict(self) -> Dict[str, Any]: raw_type = self.ecf_type.value if isinstance(self.ecf_type, Enum) else self.ecf_type data: Dict[str, Any] = { "ecf_type": int(raw_type), "sequence_ranges": [r.to_dict() if isinstance(r, BaseDTO) else r for r in self.sequence_ranges], } if self.line_number is not None: data["line_number"] = self.line_number return data @classmethod def from_dict(cls, data: Dict[str, Any]) -> "CancellationDetail": ranges_raw = data.get("sequence_ranges", []) ranges = [ SequenceRange.from_dict(r) if isinstance(r, dict) else r for r in ranges_raw ] return cls( ecf_type=int(data["ecf_type"]), sequence_ranges=ranges, line_number=data.get("line_number"), ) @dataclass class CancelRangesRequest(BaseDTO): """Payload to request sequence cancellation from DGII via Facturo.""" details: List[CancellationDetail] company_id: Optional[str] = None dgii_env: Optional[DGIIEnvType] = None def to_dict(self) -> Dict[str, Any]: data: Dict[str, Any] = { "details": [d.to_dict() if isinstance(d, BaseDTO) else d for d in self.details], } if self.company_id: data["company_id"] = self.company_id if self.dgii_env: raw_env = self.dgii_env.value if isinstance(self.dgii_env, Enum) else str(self.dgii_env) data["dgii_env"] = raw_env.strip().lower() return data @dataclass class CancelRangesResponse(BaseDTO): """Response returned by DGII ANECF service.""" rnc: str code: str name: str total_cancelled: int = 0 messages: List[str] = field(default_factory=list) signed_xml: Optional[str] = None signed_xml_path: Optional[str] = None # --------------------------------------------------------------------------------------------------- # Electronic Emission & Tracking Dataclasses # --------------------------------------------------------------------------------------------------- @dataclass class InvoiceEmissionRequest(BaseDTO): """Minimal invoice emission request.""" invoice_number: str customer_tax_id: str currency: str total_amount: float issue_date: str company_id: Optional[str] = None @dataclass class InvoiceEmissionResponse(BaseDTO): """Response after submitting minimal invoice.""" track_id: str status: str signed_xml: Optional[str] = None qr_url: Optional[str] = None security_code: Optional[str] = None emission_exact: Optional[str] = None @dataclass class ECFEmissionRequest(BaseDTO): """Full e-CF emission request payload.""" ecf: Dict[str, Any] company_id: Optional[str] = None dgii_env: Optional[DGIIEnvType] = None allow_certification_reset: bool = False def to_dict(self) -> Dict[str, Any]: data: Dict[str, Any] = { "ecf": self.ecf, "allow_certification_reset": self.allow_certification_reset, } if self.company_id: data["company_id"] = self.company_id if self.dgii_env: raw_env = self.dgii_env.value if isinstance(self.dgii_env, Enum) else str(self.dgii_env) data["dgii_env"] = raw_env.strip().lower() return data @dataclass class ECFEmissionResponse(BaseDTO): """Full e-CF emission response from Facturo/DGII.""" document_id: Optional[str] = None encf: Optional[str] = None track_id: Optional[str] = None internal_status: str = "" dgii_status: str = "" message: Optional[str] = None signed_xml_path: Optional[str] = None signed_xml: Optional[str] = None qr_url: Optional[str] = None security_code: Optional[str] = None emission_exact: Optional[str] = None @dataclass class InvoiceTrackingStatusResponse(BaseDTO): """DGII consultation status for a given track ID.""" track_id: str dgii_status: str = "" internal_status: str = "" last_status: str = "" last_updated_at: str = "" invoice_number: Optional[str] = None @dataclass class EmissionResult(BaseDTO): """Document retry or emission result.""" document_id: Optional[str] = None document_type_code: Optional[str] = None encf: Optional[str] = None track_id: Optional[str] = None internal_status: Optional[str] = None dgii_status: Optional[str] = None message: Optional[str] = None signed_xml_path: Optional[str] = None signed_xml: Optional[str] = None qr_url: Optional[str] = None signature_code: Optional[str] = None signature_datetime: Optional[str] = None created_at: Optional[str] = None total_amount: Optional[float] = None @dataclass class SignedXMLResponse(BaseDTO): """Binary signed XML content and metadata.""" content: bytes encf: Optional[str] = None file_name: Optional[str] = None sha256: Optional[str] = None # --------------------------------------------------------------------------------------------------- # Facturo Client Exceptions # --------------------------------------------------------------------------------------------------- class FacturoAPIError(UserError): """Specific exception representing errors returned by Facturo API or DGII.""" def __init__(self, message: str, status_code: Optional[int] = None, payload: Optional[Any] = None): super().__init__(message) self.status_code = status_code self.payload = payload # --------------------------------------------------------------------------------------------------- # Facturo Client Implementation # --------------------------------------------------------------------------------------------------- class FacturoClient: """HTTP Client for Facturo RD Fiscal Emission and DGII Synchronization Service.""" DEFAULT_TIMEOUT = 30 # seconds def __init__( self, base_url: str = "https://ecf.facturord.app/", api_key: str = "", company_id: Optional[str] = None, timeout: int = DEFAULT_TIMEOUT, ): """Initialize FacturoClient. Args: base_url: Base URL of Facturo API (defaults to "https://ecf.facturord.app/"). api_key: Secret API Key authorized with required scopes (e.g. documents.emit, documents.view). company_id: Default target company UUID. Can also be passed per-method. timeout: Request timeout in seconds. """ self.base_url = base_url.rstrip("/") self.api_key = api_key.strip() self.company_id = company_id.strip() if company_id else None self.timeout = timeout self.session = requests.Session() self.session.headers.update({ "X-API-Key": self.api_key, "User-Agent": "FacturoClient/1.0 (Odoo/Python)", "Accept": "application/json", }) def _resolve_company_id(self, company_id: Optional[str] = None) -> str: target_id = company_id or self.company_id if not target_id: raise FacturoAPIError("company_id is required either in FacturoClient init or method call.") return str(target_id).strip() def _build_url(self, path: str) -> str: clean_path = path.lstrip("/") if not clean_path.startswith("api/v1/"): clean_path = f"api/v1/{clean_path}" return f"{self.base_url}/{clean_path}" def _request( self, method: str, path: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, raw_response: bool = False, ) -> Union[Dict[str, Any], bytes]: url = self._build_url(path) headers = {} if json_data is not None: headers["Content-Type"] = "application/json" try: _logger.debug("Facturo API %s %s: %s", method, url, json_data) response = self.session.request( method=method, url=url, json=json_data, params=params, headers=headers, timeout=self.timeout, ) except Timeout as exc: msg = f"Facturo API timeout connecting to {url}" _logger.error(msg) raise FacturoAPIError(msg) from exc except RequestException as exc: msg = f"Facturo API communication failure: {exc}" _logger.error(msg) raise FacturoAPIError(msg) from exc if response.status_code >= 400: error_message = self._extract_error_message(response) _logger.error("Facturo API error (%d): %s", response.status_code, error_message) raise FacturoAPIError( message=f"Facturo API Error ({response.status_code}): {error_message}", status_code=response.status_code, payload=error_message, ) if raw_response: return response.content try: return response.json() except ValueError: return {"raw_text": response.text} @staticmethod def _extract_error_message(response: requests.Response) -> str: try: data = response.json() if isinstance(data, dict): return data.get("error") or data.get("message") or json.dumps(data) return str(data) except ValueError: return response.text or f"HTTP {response.status_code}" # ----------------------------------------------------------------------------------------------- # Sequence Ranges Cancellation (DGII ANECF v1.0) # ----------------------------------------------------------------------------------------------- def cancel_ranges( self, details: Union[List[CancellationDetail], List[Dict[str, Any]]], company_id: Optional[str] = None, dgii_env: Optional[DGIIEnvType] = None, ) -> CancelRangesResponse: """Submit an ANECF document to DGII to cancel unused or unaccepted sequence ranges. Args: details: List of CancellationDetail instances or dicts by e-CF type. company_id: Optional company ID override. dgii_env: Optional target DGII environment (e.g. DGIIEnv.TESTECF, "testecf"). Returns: CancelRangesResponse dataclass (also supports dict access like response["code"]). """ cid = self._resolve_company_id(company_id) serialized_details = [ d.to_dict() if isinstance(d, BaseDTO) else d for d in details ] payload: Dict[str, Any] = {"details": serialized_details} if dgii_env: raw_env = dgii_env.value if isinstance(dgii_env, Enum) else str(dgii_env) payload["dgii_env"] = raw_env.strip().lower() raw_res = self._request( method="POST", path=f"companies/{cid}/emission/cancel-ranges", json_data=payload, ) return CancelRangesResponse.from_dict(raw_res if isinstance(raw_res, dict) else {}) def cancel_single_sequence( self, ecf_type: ECFTypeValue, encf: str, company_id: Optional[str] = None, dgii_env: Optional[DGIIEnvType] = None, ) -> CancelRangesResponse: """Helper to void a single e-NCF sequence number. Args: ecf_type: Fiscal document type (e.g. ECFType.E31_CREDITO_FISCAL, 31). encf: Exactly 13-character electronic sequence (e.g. "E310000000001"). company_id: Optional company ID override. dgii_env: Optional target DGII environment (e.g. DGIIEnv.TESTECF, "testecf"). """ detail = CancellationDetail( ecf_type=ecf_type, sequence_ranges=[SequenceRange.single(encf)], ) return self.cancel_ranges(details=[detail], company_id=company_id, dgii_env=dgii_env) def cancel_sequence_range( self, ecf_type: ECFTypeValue, from_encf: str, to_encf: str, company_id: Optional[str] = None, dgii_env: Optional[DGIIEnvType] = None, ) -> CancelRangesResponse: """Helper to void a continuous range of e-NCF sequences. Args: ecf_type: Fiscal document type (e.g. ECFType.E31_CREDITO_FISCAL, 31). from_encf: Starting 13-character e-NCF (e.g. "E310000000001"). to_encf: Ending 13-character e-NCF (e.g. "E310000000010"). company_id: Optional company ID override. dgii_env: Optional target DGII environment (e.g. DGIIEnv.TESTECF, "testecf"). """ detail = CancellationDetail( ecf_type=ecf_type, sequence_ranges=[SequenceRange(from_encf=from_encf.strip(), to_encf=to_encf.strip())], ) return self.cancel_ranges(details=[detail], company_id=company_id, dgii_env=dgii_env) # ----------------------------------------------------------------------------------------------- # Electronic Invoice & e-CF Emission # ----------------------------------------------------------------------------------------------- def emit_rich_ecf( self, ecf_payload: Union[ECFEmissionRequest, Dict[str, Any]], company_id: Optional[str] = None, ) -> ECFEmissionResponse: """Submit a complete e-CF document structure for fiscal signing and DGII emission. Args: ecf_payload: ECFEmissionRequest dataclass or raw ECF JSON dict. company_id: Optional company ID override. Returns: ECFEmissionResponse dataclass. """ cid = self._resolve_company_id(company_id) json_data = ecf_payload.to_dict() if isinstance(ecf_payload, BaseDTO) else ecf_payload raw_res = self._request( method="POST", path=f"companies/{cid}/emission/ecf", json_data=json_data, ) return ECFEmissionResponse.from_dict(raw_res if isinstance(raw_res, dict) else {}) def get_tracking_status( self, track_id: str, company_id: Optional[str] = None, ) -> InvoiceTrackingStatusResponse: """Query DGII reception and validation status for an emitted track ID. Args: track_id: Tracking ID returned by DGII upon document submission. company_id: Optional company ID override. """ cid = self._resolve_company_id(company_id) raw_res = self._request( method="GET", path=f"companies/{cid}/emission/invoices/{track_id.strip()}/status", ) return InvoiceTrackingStatusResponse.from_dict(raw_res if isinstance(raw_res, dict) else {}) def get_signed_xml( self, document_id_or_encf: str, company_id: Optional[str] = None, dgii_env: Optional[DGIIEnvType] = None, download: bool = False, ) -> bytes: """Download or retrieve the digitally signed XML for an emitted document. Args: document_id_or_encf: Document UUID or 13-character e-NCF. company_id: Optional company ID override. dgii_env: Optional target DGII environment when resolving by e-NCF. download: If True, requests attachment disposition. Returns: Raw signed XML bytes. """ cid = self._resolve_company_id(company_id) params: Dict[str, Any] = {} if download: params["download"] = "true" if dgii_env: raw_env = dgii_env.value if isinstance(dgii_env, Enum) else str(dgii_env) params["dgii_env"] = raw_env.strip().lower() raw_content = self._request( method="GET", path=f"companies/{cid}/emission/documents/{document_id_or_encf.strip()}/signed-xml", params=params, raw_response=True, ) return raw_content if isinstance(raw_content, bytes) else bytes(str(raw_content), "utf-8") def retry_emission( self, document_id: str, company_id: Optional[str] = None, ) -> EmissionResult: """Retry emission of a previously failed or pending document. Args: document_id: UUID of the document in Facturo RD database. company_id: Optional company ID override. """ cid = self._resolve_company_id(company_id) raw_res = self._request( method="POST", path=f"companies/{cid}/emission/documents/{document_id.strip()}/retry", ) return EmissionResult.from_dict(raw_res if isinstance(raw_res, dict) else {})