Ir al contenido

SDK y modelosSDK y modelos: Python

SDK y modelos: Python

Ver como Markdown

Dataclasses sin dependencias externas. Cada modelo tiene to_dict() para armar el JSON del request y from_dict() para leer la respuesta. Descarga el zip completo y descomprímelo como un paquete (incluye __init__.py), por ejemplo como novafe_models.

emitir.py
import os
import uuid
from decimal import Decimal
import requests
from novafe_models.ecf import (
EcfBuyerPayload,
EcfDto,
EcfLinePayload,
EcfPaymentMethodPayload,
EcfPaymentPayload,
IssueEcfCommand,
)
payload = IssueEcfCommand(
type=31,
income_type="01",
internal_number="FAC-2026-00042",
buyer=EcfBuyerPayload(rnc="131880681", name="Mi Cliente SRL"),
payment=EcfPaymentPayload(
condition="credit",
due_date="15-03-2026",
methods=[EcfPaymentMethodPayload(type="check_transfer", amount=Decimal("2360"))],
),
lines=[
EcfLinePayload(
name="Servicio de consultoría",
kind="service",
quantity=Decimal("1"),
unit_of_measure="43",
unit_price=Decimal("2000"),
itbis_rate=1,
)
],
)
response = requests.post(
"https://api.novafe.example/api/v1/ecf",
json=payload.to_dict(),
headers={
"X-API-Key": os.environ["NOVAFE_API_KEY"],
"Idempotency-Key": str(uuid.uuid4()),
},
)
response.raise_for_status()
ecf = EcfDto.from_dict(response.json())
print(ecf.encf, ecf.status)

Todo el contenido de ecf.py, listo para copiar. Incluye IssueEcfCommand (el cuerpo de POST /ecf), EcfDto (la respuesta) y sus tipos anidados.

ecf.py
# Generado desde el documento OpenAPI publico de NovaFE. No lo edites a mano: regeneralo con `bun run models`.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from .common import _ser, _strip
@dataclass
class DgiiMessage:
code: int
value: str
def to_dict(self) -> dict[str, Any]:
return _strip({
"code": _ser(self.code),
"value": _ser(self.value),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "DgiiMessage":
return cls(
code=data["code"],
value=data["value"],
)
@dataclass
class DgiiTrackIdEntry:
"""Una entrada de `/{amb}/consultatrackids/api/trackids/consulta`. Puede haber más de una para el mismo e-NCF si se remitió varias veces."""
track_id: str
estado: str
fecha_recepcion: str | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"trackId": _ser(self.track_id),
"estado": _ser(self.estado),
"fechaRecepcion": _ser(self.fecha_recepcion),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "DgiiTrackIdEntry":
return cls(
track_id=data["trackId"],
estado=data["estado"],
fecha_recepcion=(data["fechaRecepcion"] if data.get("fechaRecepcion") is not None else None),
)
@dataclass
class EcfAdditionalInfoPayload:
"""Texto libre para la Representación Impresa."""
issuer: str | None = None
buyer: str | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"issuer": _ser(self.issuer),
"buyer": _ser(self.buyer),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfAdditionalInfoPayload":
return cls(
issuer=(data["issuer"] if data.get("issuer") is not None else None),
buyer=(data["buyer"] if data.get("buyer") is not None else None),
)
@dataclass
class EcfAdditionalTaxPayload:
"""Desglose de impuestos adicionales por código de la Tabla I (001–039). Todos los montos los trae el cliente ya calculados. `iscEspecifico` (monto fijo por volumen: alcoholes 006-018, cigarrillos 019-022) integra la base imponible del ITBIS de esa línea; `iscAdvalorem` y `otros` (Propina, CDT, ISC de servicios, Primera Placa…) van "por encima", sin tocar la base."""
code: str
rate: Decimal | None = None
isc_especifico: Decimal | None = None
isc_advalorem: Decimal | None = None
otros: Decimal | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"code": _ser(self.code),
"rate": _ser(self.rate),
"iscEspecifico": _ser(self.isc_especifico),
"iscAdvalorem": _ser(self.isc_advalorem),
"otros": _ser(self.otros),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfAdditionalTaxPayload":
return cls(
code=data["code"],
rate=(Decimal(str(data["rate"])) if data.get("rate") is not None else None),
isc_especifico=(Decimal(str(data["iscEspecifico"])) if data.get("iscEspecifico") is not None else None),
isc_advalorem=(Decimal(str(data["iscAdvalorem"])) if data.get("iscAdvalorem") is not None else None),
otros=(Decimal(str(data["otros"])) if data.get("otros") is not None else None),
)
@dataclass
class EcfAttemptDto:
"""Un renglón del historial de envíos de un comprobante: cómo terminó un ciclo contra la DGII. Lo que emite `GET /api/v1/ecf/{id}/attempts`."""
attempt: int
"""1, 2, 3… dentro del comprobante."""
outcome: str
"""En qué estado dejó el ciclo al comprobante: `accepted`, `accepted_conditional`, `rejected`, `review` o `failed`."""
messages: list[DgiiMessage]
"""Observaciones o motivo de rechazo de la DGII; o el motivo del fallo si no hubo veredicto."""
xml_sha256: str
"""SHA-256 (hex) del XML enviado en este ciclo."""
recorded_at: str
"""Cuándo se registró el resultado."""
track_id: str | None = None
"""`TrackId` de la DGII de este ciclo; ausente si no llegó a haber uno."""
status_code: int | None = None
"""Código de estado de la DGII (1 aceptado, 2 rechazado, 4 aceptado condicional); ausente si no hubo veredicto."""
status_text: str | None = None
"""El `estado` textual de la DGII."""
sequence_used: bool | None = None
"""`secuenciaUtilizada` de la DGII: `false` = el e-NCF no se consumió; `true` o ausente = consumido."""
retry_decision: str | None = None
"""Qué se decidió con un rechazo: `auto:<código>` (la DGII falló de forma transitoria y NovaFE reenvía el mismo XML solo, p. ej. `auto:4`) o `exhausted` (era reintentable pero se agotó el ladder y el rechazo quedó firme). Ausente en el resto de resultados."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"attempt": _ser(self.attempt),
"outcome": _ser(self.outcome),
"trackId": _ser(self.track_id),
"statusCode": _ser(self.status_code),
"statusText": _ser(self.status_text),
"sequenceUsed": _ser(self.sequence_used),
"messages": _ser(self.messages),
"retryDecision": _ser(self.retry_decision),
"xmlSha256": _ser(self.xml_sha256),
"recordedAt": _ser(self.recorded_at),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfAttemptDto":
return cls(
attempt=data["attempt"],
outcome=data["outcome"],
track_id=(data["trackId"] if data.get("trackId") is not None else None),
status_code=(data["statusCode"] if data.get("statusCode") is not None else None),
status_text=(data["statusText"] if data.get("statusText") is not None else None),
sequence_used=(data["sequenceUsed"] if data.get("sequenceUsed") is not None else None),
messages=[DgiiMessage.from_dict(i) for i in data["messages"]],
retry_decision=(data["retryDecision"] if data.get("retryDecision") is not None else None),
xml_sha256=data["xmlSha256"],
recorded_at=data["recordedAt"],
)
@dataclass
class EcfBuyerPayload:
"""Bloque `<Comprador>`. `Rnc` y `ForeignId` son excluyentes."""
name: str | None = None
rnc: str | None = None
foreign_id: str | None = None
email: str | None = None
contact: str | None = None
address: str | None = None
municipality: str | None = None
province: str | None = None
additional_info: str | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"name": _ser(self.name),
"rnc": _ser(self.rnc),
"foreignId": _ser(self.foreign_id),
"email": _ser(self.email),
"contact": _ser(self.contact),
"address": _ser(self.address),
"municipality": _ser(self.municipality),
"province": _ser(self.province),
"additionalInfo": _ser(self.additional_info),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfBuyerPayload":
return cls(
name=(data["name"] if data.get("name") is not None else None),
rnc=(data["rnc"] if data.get("rnc") is not None else None),
foreign_id=(data["foreignId"] if data.get("foreignId") is not None else None),
email=(data["email"] if data.get("email") is not None else None),
contact=(data["contact"] if data.get("contact") is not None else None),
address=(data["address"] if data.get("address") is not None else None),
municipality=(data["municipality"] if data.get("municipality") is not None else None),
province=(data["province"] if data.get("province") is not None else None),
additional_info=(data["additionalInfo"] if data.get("additionalInfo") is not None else None),
)
@dataclass
class EcfDeclaredTotalsPayload:
"""Totales que calculó el cliente (en DOP). Se comparan con el cálculo de NovaFE (tolerancia RF-06.6); el valor de NovaFE es el que va al XML. Todo opcional."""
monto_gravado_total: Decimal | None = None
monto_exento: Decimal | None = None
total_itbis: Decimal | None = None
monto_impuesto_adicional: Decimal | None = None
monto_total: Decimal | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"montoGravadoTotal": _ser(self.monto_gravado_total),
"montoExento": _ser(self.monto_exento),
"totalItbis": _ser(self.total_itbis),
"montoImpuestoAdicional": _ser(self.monto_impuesto_adicional),
"montoTotal": _ser(self.monto_total),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfDeclaredTotalsPayload":
return cls(
monto_gravado_total=(Decimal(str(data["montoGravadoTotal"])) if data.get("montoGravadoTotal") is not None else None),
monto_exento=(Decimal(str(data["montoExento"])) if data.get("montoExento") is not None else None),
total_itbis=(Decimal(str(data["totalItbis"])) if data.get("totalItbis") is not None else None),
monto_impuesto_adicional=(Decimal(str(data["montoImpuestoAdicional"])) if data.get("montoImpuestoAdicional") is not None else None),
monto_total=(Decimal(str(data["montoTotal"])) if data.get("montoTotal") is not None else None),
)
@dataclass
class EcfDgiiExchange:
"""El intercambio con la DGII de un comprobante: lo que ella respondió más los instantes en que NovaFE registró cada paso."""
messages: list[DgiiMessage]
"""Observaciones o motivo de rechazo que devolvió la DGII."""
track_id: str | None = None
"""Identificador que la DGII asigna al recibir el comprobante."""
status: str | None = None
"""El `estado` textual de la DGII ("Aceptado", "Rechazado", "En Proceso"…); en el idioma en que lo manda ella."""
status_code: int | None = None
"""Código de estado de la DGII: 1 aceptado · 2 rechazado · 3 en proceso · 4 aceptado condicional."""
sequence_used: bool | None = None
"""`secuenciaUtilizada` de la DGII: `false` = el e-NCF no se consumió (firma/XML inválidos); `true` o ausente = consumido."""
submitted_at: str | None = None
"""Instante en que la DGII confirmó la recepción (hay string? EcfDgiiExchange.TrackId)."""
received_at: str | None = None
"""La `fechaRecepcion` que informó la DGII; ausente si no la dio (p. ej. RFCE)."""
processed_at: str | None = None
"""Instante en que la DGII dio un resultado definitivo."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"trackId": _ser(self.track_id),
"status": _ser(self.status),
"statusCode": _ser(self.status_code),
"sequenceUsed": _ser(self.sequence_used),
"messages": _ser(self.messages),
"submittedAt": _ser(self.submitted_at),
"receivedAt": _ser(self.received_at),
"processedAt": _ser(self.processed_at),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfDgiiExchange":
return cls(
track_id=(data["trackId"] if data.get("trackId") is not None else None),
status=(data["status"] if data.get("status") is not None else None),
status_code=(data["statusCode"] if data.get("statusCode") is not None else None),
sequence_used=(data["sequenceUsed"] if data.get("sequenceUsed") is not None else None),
messages=[DgiiMessage.from_dict(i) for i in data["messages"]],
submitted_at=(data["submittedAt"] if data.get("submittedAt") is not None else None),
received_at=(data["receivedAt"] if data.get("receivedAt") is not None else None),
processed_at=(data["processedAt"] if data.get("processedAt") is not None else None),
)
@dataclass
class EcfDto:
"""Vista de un comprobante emitido (respuesta de `POST /ecf` y `GET /ecf/{id}`). Trae la identidad fiscal, el resumen comercial (comprador, monto total — igual que EcfSummaryDto) y el resultado del intercambio con la DGII (EcfDgiiExchange? EcfDto.Dgii). El desglose por línea y el resto del detalle comercial siguen viviendo solo en el XML firmado (`GET /ecf/{id}/xml`)."""
id: str
status: str
encf: str
type: int
environment: str
issue_date: str
issued_at: str
signed_at: str
security_code: str
qr_url: str
submits_rfce: bool
signed_during_contingency: bool
monto_total: Decimal
document_hash: str
sequence_expires_on: str | None = None
internal_number: str | None = None
tolerance_warning: str | None = None
buyer_rnc: str | None = None
buyer_name: str | None = None
dgii: EcfDgiiExchange | None = None
"""Lo que pasó con la DGII: `trackId`, estado (texto y código), mensajes, `secuenciaUtilizada` y los instantes de envío, recepción y resolución. `null` mientras el comprobante no se haya enviado."""
retry: EcfRetry | None = None
"""El reintento automático en curso o ya hecho: cuántas veces NovaFE reenvió el mismo comprobante tras un rechazo transitorio de la DGII, el tope, y cuándo es el próximo intento. `null` mientras no haya habido ningún reintento."""
links: EcfLinks | None = None
"""Enlaces a los recursos relacionados del comprobante."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"id": _ser(self.id),
"status": _ser(self.status),
"encf": _ser(self.encf),
"type": _ser(self.type),
"environment": _ser(self.environment),
"sequenceExpiresOn": _ser(self.sequence_expires_on),
"issueDate": _ser(self.issue_date),
"issuedAt": _ser(self.issued_at),
"signedAt": _ser(self.signed_at),
"securityCode": _ser(self.security_code),
"qrUrl": _ser(self.qr_url),
"submitsRfce": _ser(self.submits_rfce),
"internalNumber": _ser(self.internal_number),
"toleranceWarning": _ser(self.tolerance_warning),
"signedDuringContingency": _ser(self.signed_during_contingency),
"montoTotal": _ser(self.monto_total),
"buyerRnc": _ser(self.buyer_rnc),
"buyerName": _ser(self.buyer_name),
"documentHash": _ser(self.document_hash),
"dgii": _ser(self.dgii),
"retry": _ser(self.retry),
"links": _ser(self.links),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfDto":
return cls(
id=data["id"],
status=data["status"],
encf=data["encf"],
type=data["type"],
environment=data["environment"],
sequence_expires_on=(data["sequenceExpiresOn"] if data.get("sequenceExpiresOn") is not None else None),
issue_date=data["issueDate"],
issued_at=data["issuedAt"],
signed_at=data["signedAt"],
security_code=data["securityCode"],
qr_url=data["qrUrl"],
submits_rfce=data["submitsRfce"],
internal_number=(data["internalNumber"] if data.get("internalNumber") is not None else None),
tolerance_warning=(data["toleranceWarning"] if data.get("toleranceWarning") is not None else None),
signed_during_contingency=data["signedDuringContingency"],
monto_total=Decimal(str(data["montoTotal"])),
buyer_rnc=(data["buyerRnc"] if data.get("buyerRnc") is not None else None),
buyer_name=(data["buyerName"] if data.get("buyerName") is not None else None),
document_hash=data["documentHash"],
dgii=(EcfDgiiExchange.from_dict(data["dgii"]) if data.get("dgii") is not None else None),
retry=(EcfRetry.from_dict(data["retry"]) if data.get("retry") is not None else None),
links=(EcfLinks.from_dict(data["links"]) if data.get("links") is not None else None),
)
@dataclass
class EcfExportPayload:
"""Datos de exportación (solo tipo 46)."""
loading_port_name: str | None = None
delivery_terms: str | None = None
total_fob: Decimal | None = None
insurance: Decimal | None = None
freight: Decimal | None = None
other_charges: Decimal | None = None
total_cif: Decimal | None = None
customs_regime: str | None = None
departure_port_name: str | None = None
unloading_port_name: str | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"loadingPortName": _ser(self.loading_port_name),
"deliveryTerms": _ser(self.delivery_terms),
"totalFob": _ser(self.total_fob),
"insurance": _ser(self.insurance),
"freight": _ser(self.freight),
"otherCharges": _ser(self.other_charges),
"totalCif": _ser(self.total_cif),
"customsRegime": _ser(self.customs_regime),
"departurePortName": _ser(self.departure_port_name),
"unloadingPortName": _ser(self.unloading_port_name),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfExportPayload":
return cls(
loading_port_name=(data["loadingPortName"] if data.get("loadingPortName") is not None else None),
delivery_terms=(data["deliveryTerms"] if data.get("deliveryTerms") is not None else None),
total_fob=(Decimal(str(data["totalFob"])) if data.get("totalFob") is not None else None),
insurance=(Decimal(str(data["insurance"])) if data.get("insurance") is not None else None),
freight=(Decimal(str(data["freight"])) if data.get("freight") is not None else None),
other_charges=(Decimal(str(data["otherCharges"])) if data.get("otherCharges") is not None else None),
total_cif=(Decimal(str(data["totalCif"])) if data.get("totalCif") is not None else None),
customs_regime=(data["customsRegime"] if data.get("customsRegime") is not None else None),
departure_port_name=(data["departurePortName"] if data.get("departurePortName") is not None else None),
unloading_port_name=(data["unloadingPortName"] if data.get("unloadingPortName") is not None else None),
)
@dataclass
class EcfForeignCurrencyPayload:
"""Facturación en divisa (`<OtraMoneda>`). El cliente trae los montos ya convertidos."""
currency: str
exchange_rate: Decimal
totals: EcfForeignCurrencyTotalsPayload
def to_dict(self) -> dict[str, Any]:
return _strip({
"currency": _ser(self.currency),
"exchangeRate": _ser(self.exchange_rate),
"totals": _ser(self.totals),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfForeignCurrencyPayload":
return cls(
currency=data["currency"],
exchange_rate=Decimal(str(data["exchangeRate"])),
totals=EcfForeignCurrencyTotalsPayload.from_dict(data["totals"]),
)
@dataclass
class EcfForeignCurrencyTotalsPayload:
monto_gravado_total: Decimal | None = None
monto_gravado_i1: Decimal | None = None
monto_gravado_i2: Decimal | None = None
monto_gravado_i3: Decimal | None = None
monto_exento: Decimal | None = None
total_itbis: Decimal | None = None
total_itbis1: Decimal | None = None
total_itbis2: Decimal | None = None
total_itbis3: Decimal | None = None
monto_total: Decimal | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"montoGravadoTotal": _ser(self.monto_gravado_total),
"montoGravadoI1": _ser(self.monto_gravado_i1),
"montoGravadoI2": _ser(self.monto_gravado_i2),
"montoGravadoI3": _ser(self.monto_gravado_i3),
"montoExento": _ser(self.monto_exento),
"totalItbis": _ser(self.total_itbis),
"totalItbis1": _ser(self.total_itbis1),
"totalItbis2": _ser(self.total_itbis2),
"totalItbis3": _ser(self.total_itbis3),
"montoTotal": _ser(self.monto_total),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfForeignCurrencyTotalsPayload":
return cls(
monto_gravado_total=(Decimal(str(data["montoGravadoTotal"])) if data.get("montoGravadoTotal") is not None else None),
monto_gravado_i1=(Decimal(str(data["montoGravadoI1"])) if data.get("montoGravadoI1") is not None else None),
monto_gravado_i2=(Decimal(str(data["montoGravadoI2"])) if data.get("montoGravadoI2") is not None else None),
monto_gravado_i3=(Decimal(str(data["montoGravadoI3"])) if data.get("montoGravadoI3") is not None else None),
monto_exento=(Decimal(str(data["montoExento"])) if data.get("montoExento") is not None else None),
total_itbis=(Decimal(str(data["totalItbis"])) if data.get("totalItbis") is not None else None),
total_itbis1=(Decimal(str(data["totalItbis1"])) if data.get("totalItbis1") is not None else None),
total_itbis2=(Decimal(str(data["totalItbis2"])) if data.get("totalItbis2") is not None else None),
total_itbis3=(Decimal(str(data["totalItbis3"])) if data.get("totalItbis3") is not None else None),
monto_total=(Decimal(str(data["montoTotal"])) if data.get("montoTotal") is not None else None),
)
@dataclass
class EcfGlobalAdjustmentPayload:
"""Un descuento o recargo global (Sección D)."""
line: int
kind: str | None = None
affects_itbis_rate: int | None = None
amount: Decimal | None = None
norm1007: bool | None = None
description: str | None = None
percentage: Decimal | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"line": _ser(self.line),
"kind": _ser(self.kind),
"affectsItbisRate": _ser(self.affects_itbis_rate),
"amount": _ser(self.amount),
"norm1007": _ser(self.norm1007),
"description": _ser(self.description),
"percentage": _ser(self.percentage),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfGlobalAdjustmentPayload":
return cls(
line=data["line"],
kind=(data["kind"] if data.get("kind") is not None else None),
affects_itbis_rate=(data["affectsItbisRate"] if data.get("affectsItbisRate") is not None else None),
amount=(Decimal(str(data["amount"])) if data.get("amount") is not None else None),
norm1007=(data["norm1007"] if data.get("norm1007") is not None else None),
description=(data["description"] if data.get("description") is not None else None),
percentage=(Decimal(str(data["percentage"])) if data.get("percentage") is not None else None),
)
@dataclass
class EcfItemCodePayload:
type: str
value: str
def to_dict(self) -> dict[str, Any]:
return _strip({
"type": _ser(self.type),
"value": _ser(self.value),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfItemCodePayload":
return cls(
type=data["type"],
value=data["value"],
)
@dataclass
class EcfLineDetailsPayload:
"""Campos opcionales del `<Item>` — passthrough. La derivación del ISC desde `alcoholDegrees`/`referenceQuantity` es un slice posterior; hoy el cliente trae el monto final del ISC en `additionalTaxes` (el ISC específico ahí sí integra la base del ITBIS — ver EcfAdditionalTaxPayload)."""
reference_quantity: Decimal | None = None
reference_unit: str | None = None
subquantities: list[EcfSubquantityPayload] | None = None
alcohol_degrees: Decimal | None = None
reference_unit_price: Decimal | None = None
manufacture_date: str | None = None
expiry_date: str | None = None
mining: EcfMiningPayload | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"referenceQuantity": _ser(self.reference_quantity),
"referenceUnit": _ser(self.reference_unit),
"subquantities": _ser(self.subquantities),
"alcoholDegrees": _ser(self.alcohol_degrees),
"referenceUnitPrice": _ser(self.reference_unit_price),
"manufactureDate": _ser(self.manufacture_date),
"expiryDate": _ser(self.expiry_date),
"mining": _ser(self.mining),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfLineDetailsPayload":
return cls(
reference_quantity=(Decimal(str(data["referenceQuantity"])) if data.get("referenceQuantity") is not None else None),
reference_unit=(data["referenceUnit"] if data.get("referenceUnit") is not None else None),
subquantities=([EcfSubquantityPayload.from_dict(i) for i in data["subquantities"]] if data.get("subquantities") is not None else None),
alcohol_degrees=(Decimal(str(data["alcoholDegrees"])) if data.get("alcoholDegrees") is not None else None),
reference_unit_price=(Decimal(str(data["referenceUnitPrice"])) if data.get("referenceUnitPrice") is not None else None),
manufacture_date=(data["manufactureDate"] if data.get("manufactureDate") is not None else None),
expiry_date=(data["expiryDate"] if data.get("expiryDate") is not None else None),
mining=(EcfMiningPayload.from_dict(data["mining"]) if data.get("mining") is not None else None),
)
@dataclass
class EcfLineForeignCurrencyPayload:
"""`<OtraMonedaDetalle>` — precio y montos de la línea en divisa (passthrough)."""
unit_price: Decimal | None = None
discount: Decimal | None = None
surcharge: Decimal | None = None
line_amount: Decimal | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"unitPrice": _ser(self.unit_price),
"discount": _ser(self.discount),
"surcharge": _ser(self.surcharge),
"lineAmount": _ser(self.line_amount),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfLineForeignCurrencyPayload":
return cls(
unit_price=(Decimal(str(data["unitPrice"])) if data.get("unitPrice") is not None else None),
discount=(Decimal(str(data["discount"])) if data.get("discount") is not None else None),
surcharge=(Decimal(str(data["surcharge"])) if data.get("surcharge") is not None else None),
line_amount=(Decimal(str(data["lineAmount"])) if data.get("lineAmount") is not None else None),
)
@dataclass
class EcfLinePayload:
"""Una línea de `<DetallesItems>`."""
name: str | None = None
kind: str | None = None
quantity: Decimal | None = None
unit_price: Decimal | None = None
itbis_rate: int | None = None
unit_of_measure: str | None = None
description: str | None = None
discount: Decimal | None = None
surcharge: Decimal | None = None
price_includes_tax: bool | None = None
declared_amount: Decimal | None = None
codes: list[EcfItemCodePayload] | None = None
retention: EcfLineRetentionPayload | None = None
additional_taxes: list[EcfAdditionalTaxPayload] | None = None
foreign_currency: EcfLineForeignCurrencyPayload | None = None
details: EcfLineDetailsPayload | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"name": _ser(self.name),
"kind": _ser(self.kind),
"quantity": _ser(self.quantity),
"unitPrice": _ser(self.unit_price),
"itbisRate": _ser(self.itbis_rate),
"unitOfMeasure": _ser(self.unit_of_measure),
"description": _ser(self.description),
"discount": _ser(self.discount),
"surcharge": _ser(self.surcharge),
"priceIncludesTax": _ser(self.price_includes_tax),
"declaredAmount": _ser(self.declared_amount),
"codes": _ser(self.codes),
"retention": _ser(self.retention),
"additionalTaxes": _ser(self.additional_taxes),
"foreignCurrency": _ser(self.foreign_currency),
"details": _ser(self.details),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfLinePayload":
return cls(
name=(data["name"] if data.get("name") is not None else None),
kind=(data["kind"] if data.get("kind") is not None else None),
quantity=(Decimal(str(data["quantity"])) if data.get("quantity") is not None else None),
unit_price=(Decimal(str(data["unitPrice"])) if data.get("unitPrice") is not None else None),
itbis_rate=(data["itbisRate"] if data.get("itbisRate") is not None else None),
unit_of_measure=(data["unitOfMeasure"] if data.get("unitOfMeasure") is not None else None),
description=(data["description"] if data.get("description") is not None else None),
discount=(Decimal(str(data["discount"])) if data.get("discount") is not None else None),
surcharge=(Decimal(str(data["surcharge"])) if data.get("surcharge") is not None else None),
price_includes_tax=(data["priceIncludesTax"] if data.get("priceIncludesTax") is not None else None),
declared_amount=(Decimal(str(data["declaredAmount"])) if data.get("declaredAmount") is not None else None),
codes=([EcfItemCodePayload.from_dict(i) for i in data["codes"]] if data.get("codes") is not None else None),
retention=(EcfLineRetentionPayload.from_dict(data["retention"]) if data.get("retention") is not None else None),
additional_taxes=([EcfAdditionalTaxPayload.from_dict(i) for i in data["additionalTaxes"]] if data.get("additionalTaxes") is not None else None),
foreign_currency=(EcfLineForeignCurrencyPayload.from_dict(data["foreignCurrency"]) if data.get("foreignCurrency") is not None else None),
details=(EcfLineDetailsPayload.from_dict(data["details"]) if data.get("details") is not None else None),
)
@dataclass
class EcfLineRetentionPayload:
"""Área `<Retencion>` de la línea (tipos 41 y 47). Los montos los calcula el cliente."""
agent: str | None = None
itbis_withheld: Decimal | None = None
isr_withheld: Decimal | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"agent": _ser(self.agent),
"itbisWithheld": _ser(self.itbis_withheld),
"isrWithheld": _ser(self.isr_withheld),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfLineRetentionPayload":
return cls(
agent=(data["agent"] if data.get("agent") is not None else None),
itbis_withheld=(Decimal(str(data["itbisWithheld"])) if data.get("itbisWithheld") is not None else None),
isr_withheld=(Decimal(str(data["isrWithheld"])) if data.get("isrWithheld") is not None else None),
)
@dataclass
class EcfLinks:
"""Enlaces (relativos) a los recursos del comprobante."""
self: str
"""El comprobante y su estado."""
xml: str
"""El XML firmado (`<ECF>`)."""
representation: str
"""La Representación Impresa en PDF."""
rfce_xml: str | None = None
"""El resumen firmado (`<RFCE>`); solo cuando `submitsRfce`."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"self": _ser(self.self),
"xml": _ser(self.xml),
"rfceXml": _ser(self.rfce_xml),
"representation": _ser(self.representation),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfLinks":
return cls(
self=data["self"],
xml=data["xml"],
rfce_xml=(data["rfceXml"] if data.get("rfceXml") is not None else None),
representation=data["representation"],
)
@dataclass
class EcfMiningPayload:
"""`<Mineria>` — datos de liquidación minera (solo tipos 32/33/34/46)."""
net_weight_kilogram: Decimal | None = None
net_weight_mining: Decimal | None = None
affiliation_type: int | None = None
settlement: int | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"netWeightKilogram": _ser(self.net_weight_kilogram),
"netWeightMining": _ser(self.net_weight_mining),
"affiliationType": _ser(self.affiliation_type),
"settlement": _ser(self.settlement),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfMiningPayload":
return cls(
net_weight_kilogram=(Decimal(str(data["netWeightKilogram"])) if data.get("netWeightKilogram") is not None else None),
net_weight_mining=(Decimal(str(data["netWeightMining"])) if data.get("netWeightMining") is not None else None),
affiliation_type=(data["affiliationType"] if data.get("affiliationType") is not None else None),
settlement=(data["settlement"] if data.get("settlement") is not None else None),
)
@dataclass
class EcfPagePayload:
"""Página de la RI (`<Paginacion>`)."""
number: int | None = None
line_from: int | None = None
line_to: int | None = None
monto_gravado_total: Decimal | None = None
monto_gravado_i1: Decimal | None = None
monto_gravado_i2: Decimal | None = None
monto_gravado_i3: Decimal | None = None
monto_exento: Decimal | None = None
total_itbis: Decimal | None = None
itbis1: Decimal | None = None
itbis2: Decimal | None = None
itbis3: Decimal | None = None
monto_impuesto_adicional: Decimal | None = None
isc_especifico: Decimal | None = None
otros_impuestos: Decimal | None = None
amount: Decimal | None = None
non_invoiceable_amount: Decimal | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"number": _ser(self.number),
"lineFrom": _ser(self.line_from),
"lineTo": _ser(self.line_to),
"montoGravadoTotal": _ser(self.monto_gravado_total),
"montoGravadoI1": _ser(self.monto_gravado_i1),
"montoGravadoI2": _ser(self.monto_gravado_i2),
"montoGravadoI3": _ser(self.monto_gravado_i3),
"montoExento": _ser(self.monto_exento),
"totalItbis": _ser(self.total_itbis),
"itbis1": _ser(self.itbis1),
"itbis2": _ser(self.itbis2),
"itbis3": _ser(self.itbis3),
"montoImpuestoAdicional": _ser(self.monto_impuesto_adicional),
"iscEspecifico": _ser(self.isc_especifico),
"otrosImpuestos": _ser(self.otros_impuestos),
"amount": _ser(self.amount),
"nonInvoiceableAmount": _ser(self.non_invoiceable_amount),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfPagePayload":
return cls(
number=(data["number"] if data.get("number") is not None else None),
line_from=(data["lineFrom"] if data.get("lineFrom") is not None else None),
line_to=(data["lineTo"] if data.get("lineTo") is not None else None),
monto_gravado_total=(Decimal(str(data["montoGravadoTotal"])) if data.get("montoGravadoTotal") is not None else None),
monto_gravado_i1=(Decimal(str(data["montoGravadoI1"])) if data.get("montoGravadoI1") is not None else None),
monto_gravado_i2=(Decimal(str(data["montoGravadoI2"])) if data.get("montoGravadoI2") is not None else None),
monto_gravado_i3=(Decimal(str(data["montoGravadoI3"])) if data.get("montoGravadoI3") is not None else None),
monto_exento=(Decimal(str(data["montoExento"])) if data.get("montoExento") is not None else None),
total_itbis=(Decimal(str(data["totalItbis"])) if data.get("totalItbis") is not None else None),
itbis1=(Decimal(str(data["itbis1"])) if data.get("itbis1") is not None else None),
itbis2=(Decimal(str(data["itbis2"])) if data.get("itbis2") is not None else None),
itbis3=(Decimal(str(data["itbis3"])) if data.get("itbis3") is not None else None),
monto_impuesto_adicional=(Decimal(str(data["montoImpuestoAdicional"])) if data.get("montoImpuestoAdicional") is not None else None),
isc_especifico=(Decimal(str(data["iscEspecifico"])) if data.get("iscEspecifico") is not None else None),
otros_impuestos=(Decimal(str(data["otrosImpuestos"])) if data.get("otrosImpuestos") is not None else None),
amount=(Decimal(str(data["amount"])) if data.get("amount") is not None else None),
non_invoiceable_amount=(Decimal(str(data["nonInvoiceableAmount"])) if data.get("nonInvoiceableAmount") is not None else None),
)
@dataclass
class EcfPaymentMethodPayload:
"""Una forma de pago. `Type` y `Amount` son nullable para distinguir "no vino" de un valor: el validador exige ambos y la API nunca rellena uno por su cuenta."""
type: str | None = None
amount: Decimal | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"type": _ser(self.type),
"amount": _ser(self.amount),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfPaymentMethodPayload":
return cls(
type=(data["type"] if data.get("type") is not None else None),
amount=(Decimal(str(data["amount"])) if data.get("amount") is not None else None),
)
@dataclass
class EcfPaymentPayload:
"""Bloque de pago del encabezado."""
condition: str | None = None
due_date: str | None = None
methods: list[EcfPaymentMethodPayload] | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"condition": _ser(self.condition),
"dueDate": _ser(self.due_date),
"methods": _ser(self.methods),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfPaymentPayload":
return cls(
condition=(data["condition"] if data.get("condition") is not None else None),
due_date=(data["dueDate"] if data.get("dueDate") is not None else None),
methods=([EcfPaymentMethodPayload.from_dict(i) for i in data["methods"]] if data.get("methods") is not None else None),
)
@dataclass
class EcfReferencePayload:
"""Sección `<InformacionReferencia>` — Notas de Crédito/Débito y reemplazos."""
modified_ncf: str
modified_ncf_date: str
modification_code: str | None = None
other_issuer_rnc: str | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"modifiedNcf": _ser(self.modified_ncf),
"modifiedNcfDate": _ser(self.modified_ncf_date),
"modificationCode": _ser(self.modification_code),
"otherIssuerRnc": _ser(self.other_issuer_rnc),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfReferencePayload":
return cls(
modified_ncf=data["modifiedNcf"],
modified_ncf_date=data["modifiedNcfDate"],
modification_code=(data["modificationCode"] if data.get("modificationCode") is not None else None),
other_issuer_rnc=(data["otherIssuerRnc"] if data.get("otherIssuerRnc") is not None else None),
)
@dataclass
class EcfRetry:
"""Reintento automático de un comprobante tras un rechazo transitorio de la DGII."""
count: int
"""Reenvíos automáticos que NovaFE ya hizo de este comprobante."""
max: int
"""Tope de reenvíos automáticos. Al agotarse, el rechazo queda firme."""
next_attempt_at: str | None = None
"""Cuándo es el próximo intento; `null` si no hay uno pendiente."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"count": _ser(self.count),
"max": _ser(self.max),
"nextAttemptAt": _ser(self.next_attempt_at),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfRetry":
return cls(
count=data["count"],
max=data["max"],
next_attempt_at=(data["nextAttemptAt"] if data.get("nextAttemptAt") is not None else None),
)
@dataclass
class EcfShippingPayload:
"""Datos de embarque (`<InformacionesAdicionales>`). Passthrough."""
shipment_date: str | None = None
shipment_number: str | None = None
container_number: str | None = None
reference_number: str | None = None
gross_weight: Decimal | None = None
net_weight: Decimal | None = None
gross_weight_unit: str | None = None
net_weight_unit: str | None = None
package_count: Decimal | None = None
package_unit: str | None = None
volume: Decimal | None = None
volume_unit: str | None = None
export: EcfExportPayload | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"shipmentDate": _ser(self.shipment_date),
"shipmentNumber": _ser(self.shipment_number),
"containerNumber": _ser(self.container_number),
"referenceNumber": _ser(self.reference_number),
"grossWeight": _ser(self.gross_weight),
"netWeight": _ser(self.net_weight),
"grossWeightUnit": _ser(self.gross_weight_unit),
"netWeightUnit": _ser(self.net_weight_unit),
"packageCount": _ser(self.package_count),
"packageUnit": _ser(self.package_unit),
"volume": _ser(self.volume),
"volumeUnit": _ser(self.volume_unit),
"export": _ser(self.export),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfShippingPayload":
return cls(
shipment_date=(data["shipmentDate"] if data.get("shipmentDate") is not None else None),
shipment_number=(data["shipmentNumber"] if data.get("shipmentNumber") is not None else None),
container_number=(data["containerNumber"] if data.get("containerNumber") is not None else None),
reference_number=(data["referenceNumber"] if data.get("referenceNumber") is not None else None),
gross_weight=(Decimal(str(data["grossWeight"])) if data.get("grossWeight") is not None else None),
net_weight=(Decimal(str(data["netWeight"])) if data.get("netWeight") is not None else None),
gross_weight_unit=(data["grossWeightUnit"] if data.get("grossWeightUnit") is not None else None),
net_weight_unit=(data["netWeightUnit"] if data.get("netWeightUnit") is not None else None),
package_count=(Decimal(str(data["packageCount"])) if data.get("packageCount") is not None else None),
package_unit=(data["packageUnit"] if data.get("packageUnit") is not None else None),
volume=(Decimal(str(data["volume"])) if data.get("volume") is not None else None),
volume_unit=(data["volumeUnit"] if data.get("volumeUnit") is not None else None),
export=(EcfExportPayload.from_dict(data["export"]) if data.get("export") is not None else None),
)
@dataclass
class EcfSubquantityPayload:
quantity: Decimal
unit_code: str
def to_dict(self) -> dict[str, Any]:
return _strip({
"quantity": _ser(self.quantity),
"unitCode": _ser(self.unit_code),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfSubquantityPayload":
return cls(
quantity=Decimal(str(data["quantity"])),
unit_code=data["unitCode"],
)
@dataclass
class EcfSubtotalPayload:
"""Subtotal informativo para la RI (Sección C). No afecta la base imponible."""
number: int | None = None
description: str | None = None
order: int | None = None
monto_gravado_total: Decimal | None = None
monto_gravado_i1: Decimal | None = None
monto_gravado_i2: Decimal | None = None
monto_gravado_i3: Decimal | None = None
total_itbis: Decimal | None = None
itbis1: Decimal | None = None
itbis2: Decimal | None = None
itbis3: Decimal | None = None
monto_impuesto_adicional: Decimal | None = None
monto_exento: Decimal | None = None
amount: Decimal | None = None
lines: int | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"number": _ser(self.number),
"description": _ser(self.description),
"order": _ser(self.order),
"montoGravadoTotal": _ser(self.monto_gravado_total),
"montoGravadoI1": _ser(self.monto_gravado_i1),
"montoGravadoI2": _ser(self.monto_gravado_i2),
"montoGravadoI3": _ser(self.monto_gravado_i3),
"totalItbis": _ser(self.total_itbis),
"itbis1": _ser(self.itbis1),
"itbis2": _ser(self.itbis2),
"itbis3": _ser(self.itbis3),
"montoImpuestoAdicional": _ser(self.monto_impuesto_adicional),
"montoExento": _ser(self.monto_exento),
"amount": _ser(self.amount),
"lines": _ser(self.lines),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfSubtotalPayload":
return cls(
number=(data["number"] if data.get("number") is not None else None),
description=(data["description"] if data.get("description") is not None else None),
order=(data["order"] if data.get("order") is not None else None),
monto_gravado_total=(Decimal(str(data["montoGravadoTotal"])) if data.get("montoGravadoTotal") is not None else None),
monto_gravado_i1=(Decimal(str(data["montoGravadoI1"])) if data.get("montoGravadoI1") is not None else None),
monto_gravado_i2=(Decimal(str(data["montoGravadoI2"])) if data.get("montoGravadoI2") is not None else None),
monto_gravado_i3=(Decimal(str(data["montoGravadoI3"])) if data.get("montoGravadoI3") is not None else None),
total_itbis=(Decimal(str(data["totalItbis"])) if data.get("totalItbis") is not None else None),
itbis1=(Decimal(str(data["itbis1"])) if data.get("itbis1") is not None else None),
itbis2=(Decimal(str(data["itbis2"])) if data.get("itbis2") is not None else None),
itbis3=(Decimal(str(data["itbis3"])) if data.get("itbis3") is not None else None),
monto_impuesto_adicional=(Decimal(str(data["montoImpuestoAdicional"])) if data.get("montoImpuestoAdicional") is not None else None),
monto_exento=(Decimal(str(data["montoExento"])) if data.get("montoExento") is not None else None),
amount=(Decimal(str(data["amount"])) if data.get("amount") is not None else None),
lines=(data["lines"] if data.get("lines") is not None else None),
)
@dataclass
class EcfSummaryDto:
"""Fila del listado de comprobantes emitidos."""
id: str
status: str
encf: str
type: int
environment: str
issue_date: str
monto_total: Decimal
created_at: str
buyer_rnc: str | None = None
buyer_name: str | None = None
retry: EcfRetry | None = None
"""Reintento automático (ver EcfRetry? EcfDto.Retry); `null` si no hubo ninguno."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"id": _ser(self.id),
"status": _ser(self.status),
"encf": _ser(self.encf),
"type": _ser(self.type),
"environment": _ser(self.environment),
"issueDate": _ser(self.issue_date),
"montoTotal": _ser(self.monto_total),
"buyerRnc": _ser(self.buyer_rnc),
"buyerName": _ser(self.buyer_name),
"createdAt": _ser(self.created_at),
"retry": _ser(self.retry),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfSummaryDto":
return cls(
id=data["id"],
status=data["status"],
encf=data["encf"],
type=data["type"],
environment=data["environment"],
issue_date=data["issueDate"],
monto_total=Decimal(str(data["montoTotal"])),
buyer_rnc=(data["buyerRnc"] if data.get("buyerRnc") is not None else None),
buyer_name=(data["buyerName"] if data.get("buyerName") is not None else None),
created_at=data["createdAt"],
retry=(EcfRetry.from_dict(data["retry"]) if data.get("retry") is not None else None),
)
@dataclass
class EcfTransportPayload:
"""Datos de transporte (`<Transporte>`). Passthrough."""
driver: str | None = None
transport_document: str | None = None
vehicle_id: str | None = None
plate: str | None = None
route: str | None = None
zone: str | None = None
delivery_note: str | None = None
via: str | None = None
origin_country: str | None = None
destination_address: str | None = None
destination_country: str | None = None
carrier_rnc: str | None = None
carrier_name: str | None = None
voyage_number: str | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"driver": _ser(self.driver),
"transportDocument": _ser(self.transport_document),
"vehicleId": _ser(self.vehicle_id),
"plate": _ser(self.plate),
"route": _ser(self.route),
"zone": _ser(self.zone),
"deliveryNote": _ser(self.delivery_note),
"via": _ser(self.via),
"originCountry": _ser(self.origin_country),
"destinationAddress": _ser(self.destination_address),
"destinationCountry": _ser(self.destination_country),
"carrierRnc": _ser(self.carrier_rnc),
"carrierName": _ser(self.carrier_name),
"voyageNumber": _ser(self.voyage_number),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EcfTransportPayload":
return cls(
driver=(data["driver"] if data.get("driver") is not None else None),
transport_document=(data["transportDocument"] if data.get("transportDocument") is not None else None),
vehicle_id=(data["vehicleId"] if data.get("vehicleId") is not None else None),
plate=(data["plate"] if data.get("plate") is not None else None),
route=(data["route"] if data.get("route") is not None else None),
zone=(data["zone"] if data.get("zone") is not None else None),
delivery_note=(data["deliveryNote"] if data.get("deliveryNote") is not None else None),
via=(data["via"] if data.get("via") is not None else None),
origin_country=(data["originCountry"] if data.get("originCountry") is not None else None),
destination_address=(data["destinationAddress"] if data.get("destinationAddress") is not None else None),
destination_country=(data["destinationCountry"] if data.get("destinationCountry") is not None else None),
carrier_rnc=(data["carrierRnc"] if data.get("carrierRnc") is not None else None),
carrier_name=(data["carrierName"] if data.get("carrierName") is not None else None),
voyage_number=(data["voyageNumber"] if data.get("voyageNumber") is not None else None),
)
@dataclass
class IssueEcfCommand:
"""Payload de emisión de un e-CF (`POST /api/v1/ecf`). Un solo objeto discriminado por int IssueEcfCommand.Type; el servidor asigna la secuencia, arma el bloque Emisor desde el perfil del tenant, calcula los totales, firma y persiste."""
type: int
"""Código DGII del tipo de e-CF: 31, 32, 33, 34, 41, 43, 44, 45, 46, 47."""
lines: list[EcfLinePayload]
encf: str | None = None
"""e-NCF de 13 caracteres. Solo con `sequences.mode = external` (y obligatorio ahí); con `managed` se rechaza, NovaFE asigna el suyo."""
sequence_expires_on: str | None = None
"""`<FechaVencimientoSecuencia>` del e-NCF. Con `sequences.mode = external` es obligatorio en los tipos que llevan vencimiento (todos menos 32 y 34) y no aplica en esos dos; en `managed` se rechaza."""
issue_date: str | None = None
"""Fecha de emisión (calendario dominicano). Default: hoy."""
income_type: str | None = None
"""`<TipoIngresos>` — "01"…"06". Obligatorio en 31/32/33/34/44/45/46."""
prices_include_tax: bool | None = None
"""`true` si los precios de las líneas ya traen el ITBIS incluido."""
deferred_delivery: bool | None = None
"""`<IndicadorEnvioDiferido>` — solo contribuyentes autorizados."""
non_invoiceable_amount: Decimal | None = None
"""`<MontoNoFacturable>` — reembolsos, propina voluntaria. Puede ser negativo."""
internal_number: str | None = None
"""`<NumeroFacturaInterna>` — clave de dedup de negocio."""
seller_code: str | None = None
"""`<CodigoVendedor>`."""
additional_info: EcfAdditionalInfoPayload | None = None
buyer: EcfBuyerPayload | None = None
payment: EcfPaymentPayload | None = None
reference: EcfReferencePayload | None = None
global_adjustments: list[EcfGlobalAdjustmentPayload] | None = None
foreign_currency: EcfForeignCurrencyPayload | None = None
shipping: EcfShippingPayload | None = None
transport: EcfTransportPayload | None = None
subtotals: list[EcfSubtotalPayload] | None = None
pagination: list[EcfPagePayload] | None = None
declared_totals: EcfDeclaredTotalsPayload | None = None
"""Totales declarados por el cliente (chequeo de tolerancia; nunca bloquea)."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"type": _ser(self.type),
"encf": _ser(self.encf),
"sequenceExpiresOn": _ser(self.sequence_expires_on),
"issueDate": _ser(self.issue_date),
"incomeType": _ser(self.income_type),
"pricesIncludeTax": _ser(self.prices_include_tax),
"deferredDelivery": _ser(self.deferred_delivery),
"nonInvoiceableAmount": _ser(self.non_invoiceable_amount),
"internalNumber": _ser(self.internal_number),
"sellerCode": _ser(self.seller_code),
"additionalInfo": _ser(self.additional_info),
"buyer": _ser(self.buyer),
"payment": _ser(self.payment),
"lines": _ser(self.lines),
"reference": _ser(self.reference),
"globalAdjustments": _ser(self.global_adjustments),
"foreignCurrency": _ser(self.foreign_currency),
"shipping": _ser(self.shipping),
"transport": _ser(self.transport),
"subtotals": _ser(self.subtotals),
"pagination": _ser(self.pagination),
"declaredTotals": _ser(self.declared_totals),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "IssueEcfCommand":
return cls(
type=data["type"],
encf=(data["encf"] if data.get("encf") is not None else None),
sequence_expires_on=(data["sequenceExpiresOn"] if data.get("sequenceExpiresOn") is not None else None),
issue_date=(data["issueDate"] if data.get("issueDate") is not None else None),
income_type=(data["incomeType"] if data.get("incomeType") is not None else None),
prices_include_tax=(data["pricesIncludeTax"] if data.get("pricesIncludeTax") is not None else None),
deferred_delivery=(data["deferredDelivery"] if data.get("deferredDelivery") is not None else None),
non_invoiceable_amount=(Decimal(str(data["nonInvoiceableAmount"])) if data.get("nonInvoiceableAmount") is not None else None),
internal_number=(data["internalNumber"] if data.get("internalNumber") is not None else None),
seller_code=(data["sellerCode"] if data.get("sellerCode") is not None else None),
additional_info=(EcfAdditionalInfoPayload.from_dict(data["additionalInfo"]) if data.get("additionalInfo") is not None else None),
buyer=(EcfBuyerPayload.from_dict(data["buyer"]) if data.get("buyer") is not None else None),
payment=(EcfPaymentPayload.from_dict(data["payment"]) if data.get("payment") is not None else None),
lines=[EcfLinePayload.from_dict(i) for i in data["lines"]],
reference=(EcfReferencePayload.from_dict(data["reference"]) if data.get("reference") is not None else None),
global_adjustments=([EcfGlobalAdjustmentPayload.from_dict(i) for i in data["globalAdjustments"]] if data.get("globalAdjustments") is not None else None),
foreign_currency=(EcfForeignCurrencyPayload.from_dict(data["foreignCurrency"]) if data.get("foreignCurrency") is not None else None),
shipping=(EcfShippingPayload.from_dict(data["shipping"]) if data.get("shipping") is not None else None),
transport=(EcfTransportPayload.from_dict(data["transport"]) if data.get("transport") is not None else None),
subtotals=([EcfSubtotalPayload.from_dict(i) for i in data["subtotals"]] if data.get("subtotals") is not None else None),
pagination=([EcfPagePayload.from_dict(i) for i in data["pagination"]] if data.get("pagination") is not None else None),
declared_totals=(EcfDeclaredTotalsPayload.from_dict(data["declaredTotals"]) if data.get("declaredTotals") is not None else None),
)
@dataclass
class PagedResultOfEcfSummaryDto:
items: list[EcfSummaryDto]
total_count: int
page: int
page_size: int
total_pages: int | None = None
has_next_page: bool | None = None
has_previous_page: bool | None = None
def to_dict(self) -> dict[str, Any]:
return _strip({
"items": _ser(self.items),
"totalCount": _ser(self.total_count),
"page": _ser(self.page),
"pageSize": _ser(self.page_size),
"totalPages": _ser(self.total_pages),
"hasNextPage": _ser(self.has_next_page),
"hasPreviousPage": _ser(self.has_previous_page),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PagedResultOfEcfSummaryDto":
return cls(
items=[EcfSummaryDto.from_dict(i) for i in data["items"]],
total_count=data["totalCount"],
page=data["page"],
page_size=data["pageSize"],
total_pages=(data["totalPages"] if data.get("totalPages") is not None else None),
has_next_page=(data["hasNextPage"] if data.get("hasNextPage") is not None else None),
has_previous_page=(data["hasPreviousPage"] if data.get("hasPreviousPage") is not None else None),
)
@dataclass
class RepresentationLayout:
pass
def to_dict(self) -> dict[str, Any]:
return _strip({
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "RepresentationLayout":
return cls(
)
@dataclass
class ValidateEcfIssueDto:
"""Un error o aviso de `POST /api/v1/ecf/validate`."""
code: str
"""Código estable, apto para lógica del cliente."""
message: str
"""Descripción lista para mostrar."""
field: str | None = None
"""Campo del payload al que se refiere (p. ej. `Lines[0].Quantity`); `null` si es de todo el comprobante."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"field": _ser(self.field),
"code": _ser(self.code),
"message": _ser(self.message),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ValidateEcfIssueDto":
return cls(
field=(data["field"] if data.get("field") is not None else None),
code=data["code"],
message=data["message"],
)
@dataclass
class ValidateEcfPreviewDto:
"""Vista previa de `POST /api/v1/ecf/validate` para un payload válido: pasó la matriz de validación por tipo (Módulo 2) y el cálculo de totales (Módulo 6) sin asignar una secuencia real, firmar ni persistir nada."""
type: int
type_name: str
sample_encf: str
"""Placeholder (`E{tipo}0000000000`) — nunca un e-NCF real, no se asignó ninguna secuencia para construir este documento."""
issue_date: str
monto_gravado_total: Decimal
monto_exento: Decimal
total_itbis: Decimal
monto_impuesto_adicional: Decimal
monto_total: Decimal
expect_conditional_acceptance: bool
"""Los totales quedarían fuera de la tolerancia de cuadratura (RF-06.6) — la DGII probablemente aceptaría el comprobante de forma condicional si se emite así."""
sequence_expires_on_estimate: str | None = None
"""Estimado (31-dic del año siguiente a DateOnly ValidateEcfPreviewDto.IssueDate) cuando el tipo lo lleva; no viene de una secuencia real. `null` en los tipos que no lo llevan (32, 34)."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"type": _ser(self.type),
"typeName": _ser(self.type_name),
"sampleEncf": _ser(self.sample_encf),
"issueDate": _ser(self.issue_date),
"sequenceExpiresOnEstimate": _ser(self.sequence_expires_on_estimate),
"montoGravadoTotal": _ser(self.monto_gravado_total),
"montoExento": _ser(self.monto_exento),
"totalItbis": _ser(self.total_itbis),
"montoImpuestoAdicional": _ser(self.monto_impuesto_adicional),
"montoTotal": _ser(self.monto_total),
"expectConditionalAcceptance": _ser(self.expect_conditional_acceptance),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ValidateEcfPreviewDto":
return cls(
type=data["type"],
type_name=data["typeName"],
sample_encf=data["sampleEncf"],
issue_date=data["issueDate"],
sequence_expires_on_estimate=(data["sequenceExpiresOnEstimate"] if data.get("sequenceExpiresOnEstimate") is not None else None),
monto_gravado_total=Decimal(str(data["montoGravadoTotal"])),
monto_exento=Decimal(str(data["montoExento"])),
total_itbis=Decimal(str(data["totalItbis"])),
monto_impuesto_adicional=Decimal(str(data["montoImpuestoAdicional"])),
monto_total=Decimal(str(data["montoTotal"])),
expect_conditional_acceptance=data["expectConditionalAcceptance"],
)
@dataclass
class ValidateEcfResultDto:
"""Resultado de `POST /api/v1/ecf/validate`. Siempre llega con `200` cuando el cuerpo se pudo leer y el contribuyente puede validar: bool ValidateEcfResultDto.Valid dice si el comprobante pasaría la validación de forma y la matriz estructural y fiscal por tipo (Módulo 2 + 6) que corre `POST /ecf`. No asigna una secuencia real, no firma y no persiste nada. Lo que impide validar (sin autenticar, perfil del emisor sin configurar) sigue siendo un error HTTP."""
valid: bool
"""`true` si el comprobante se podría emitir tal cual en cuanto a su contenido."""
message: str
"""Resumen en una frase, listo para mostrar."""
errors: list[ValidateEcfIssueDto]
"""Todos los problemas encontrados, no solo el primero. Vacío si bool ValidateEcfResultDto.Valid."""
warnings: list[ValidateEcfIssueDto]
"""Avisos que no impiden emitir (p. ej. aceptación condicional probable)."""
preview: ValidateEcfPreviewDto | None = None
"""La vista previa de lo que se emitiría; `null` si el comprobante no es válido."""
def to_dict(self) -> dict[str, Any]:
return _strip({
"valid": _ser(self.valid),
"message": _ser(self.message),
"errors": _ser(self.errors),
"warnings": _ser(self.warnings),
"preview": _ser(self.preview),
})
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ValidateEcfResultDto":
return cls(
valid=data["valid"],
message=data["message"],
errors=[ValidateEcfIssueDto.from_dict(i) for i in data["errors"]],
warnings=[ValidateEcfIssueDto.from_dict(i) for i in data["warnings"]],
preview=(ValidateEcfPreviewDto.from_dict(data["preview"]) if data.get("preview") is not None else None),
)