Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/farojas85/fast-rest-api/llms.txt

Use this file to discover all available pages before exploring further.

Hexagonal Architecture was chosen for DIAN REST API to keep the billing domain — tax concept validation, voluntary tip exclusion from the taxable base, invoice total reconciliation, and buyer identity — completely isolated from infrastructure details like SOAP envelopes and SQL schemas. Because src/domain/entities/ and src/application/use_cases/ never import from src/infrastructure/, the entire business logic tier can be exercised in pure unit tests with simple Python mocks, no running database and no network call required. When the DIAN changes its WSDL or the database is swapped from PostgreSQL to another engine, only the adapter implementation changes; the domain and use case layers are untouched.

The Three Layers

1. Domain — Pure Entities

src/domain/entities/pedido_local.py contains the canonical model of a restaurant invoice. Every class is a plain Pydantic model with no imports from infrastructure or application. Tax rates, item quantities, payment methods, and buyer data are all expressed here.
from decimal import Decimal
from typing import List
from pydantic import BaseModel, Field

class ConceptoImpuesto(BaseModel):
    nombre: str = Field(..., description="Nombre del impuesto, e.g., INC, IVA")
    tasa: Decimal = Field(..., description="Tasa del impuesto, e.g., 0.08 para 8%")
    monto: Decimal = Field(..., description="Valor calculado del impuesto")

class ItemPedido(BaseModel):
    id_producto: str
    descripcion: str
    cantidad: int
    precio_unitario: Decimal
    impuestos: List[ConceptoImpuesto] = Field(default_factory=list)
    total_item: Decimal

class MetodoPago(BaseModel):
    tipo: str = Field(..., description="Efectivo, Tarjeta, Transferencia, etc")
    monto: Decimal

class Adquirente(BaseModel):
    tipo_documento: str = Field(default="13", description="Tipo de documento (13=CC, 31=NIT, etc.)")
    numero_documento: str = Field(default="222222222222", description="Número de identificación")
    razon_social: str = Field(default="Consumidor Final", description="Nombre o Razón social")

class PedidoLocal(BaseModel):
    id_pedido: str
    adquirente: Adquirente = Field(default_factory=Adquirente, description="Datos del cliente")
    items: List[ItemPedido]
    subtotal: Decimal
    propina_voluntaria: Decimal = Field(default=Decimal('0.00'), description="Propina, no hace parte de base gravable")
    impuestos_totales: Decimal
    total_factura: Decimal
    metodos_pago: List[MetodoPago]

    # Regla: puramente entidad de dominio, sin base de datos o lógica externa.
PedidoLocal and its nested models have zero imports from src/application/ or src/infrastructure/. This is a hard architectural constraint: the domain layer must never depend on an outer ring.

2. Application — Port Interfaces

The application layer defines the contracts that infrastructure must honor. Two Protocol interfaces act as the outward-facing ports for the use case. src/application/ports/dian_port.py — the DIAN SOAP output port:
from typing import Protocol, Any, Dict

class IDianSoapPort(Protocol):
    """
    Puerto (Interfaz) de salida para la comunicación con la DIAN.
    La implementación (Zeep o HTTPX) reside en infraestructura.
    """
    async def send_invoice(self, invoice_data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Envía una factura electrónica a la DIAN.
        """
        ...
        
    async def send_credit_note(self, note_data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Envía una nota crédito a la DIAN.
        """
        ...
src/application/ports/log_repository.py — the audit log output port:
from typing import Protocol, Dict, Any

class IInvoiceLogRepositoryPort(Protocol):
    """
    Puerto (Interfaz) para el guardado de los logs de transacciones de facturación.
    La implementación (SQLAlchemy) reside en infraestructura.
    """
    async def save_log(self, status: str, payload_in: Dict[str, Any], response_out: Dict[str, Any] = None) -> int:
        """
        Guarda un nuevo registro o actualiza el estado de la transacción.
        """
        ...
Both are typing.Protocol definitions, meaning any class that implements the required async methods satisfies the interface — no inheritance required. This enables lightweight mock adapters in tests without subclassing anything.

3. Infrastructure — Adapters

Driving adapters (primary) — FastAPI controllers translate inbound HTTP requests into use case calls. The controller in src/infrastructure/controllers/pedido_local_controller.py deserializes the request body into PedidoLocalCreateRequest, calls use_case.execute(payload), and maps the result to an HTTP response. Driven adapters (secondary)DianSoapAdapter in src/infrastructure/adapters/dian_soap/dian_adapter.py implements IDianSoapPort. It constructs a zeep.AsyncClient backed by an httpx.AsyncClient with a 30-second timeout, then calls service.SendTestSetAsync(fileName, contentFile, testSetId) against the DIAN WSDL. A future PostgreSQL adapter will implement IInvoiceLogRepositoryPort using SQLAlchemy async sessions.

Use Case: FacturarPedidoLocalUseCase

The use case in src/application/use_cases/facturar_pedido_local.py receives both ports through constructor injection and orchestrates the full billing flow:
class FacturarPedidoLocalUseCase:
    """
    Caso de uso para registrar y facturar un pedido de consumo local.
    Sigue directrices de Arquitectura Hexagonal.
    """
    def __init__(
        self, 
        dian_port: IDianSoapPort, 
        log_repository: IInvoiceLogRepositoryPort
    ):
        self.dian_port = dian_port
        self.log_repository = log_repository

    async def execute(self, payload: PedidoLocalCreateRequest) -> PedidoLocalResponse:
        """
        1. Validar reglas de negocio (ej. totales vs items, propinas no en base gravable).
        2. Mapear DTO a Entidad de Dominio.
        3. Registrar intento de factura en DB (Estado: Procesando).
        4. Llamar adaptador DIAN SOAP.
        5. Actualizar Log según respuesta.
        6. Retornar DTO.
        """
        # Step 1 — Business validation
        expected_total = payload.subtotal + payload.impuestos_totales + payload.propina_voluntaria
        if payload.total_factura != expected_total:
            raise ValueError("El total de la factura no coincide con la suma del subtotal, impuestos y propina.")
        
        # Step 2 — Map DTO to domain entities
        items_dominio = [
            ItemPedido(
                id_producto=item.id_producto,
                descripcion=item.descripcion,
                cantidad=item.cantidad,
                precio_unitario=item.precio_unitario,
                impuestos=[
                    ConceptoImpuesto(
                        nombre=imp.nombre,
                        tasa=imp.tasa,
                        monto=imp.monto
                    ) for imp in item.impuestos
                ],
                total_item=item.total_item
            ) for item in payload.items
        ]
        metodos_pago = [MetodoPago(tipo=m.tipo, monto=m.monto) for m in payload.metodos_pago]
        adquirente_dominio = Adquirente(
            tipo_documento=payload.adquirente.tipo_documento,
            numero_documento=payload.adquirente.numero_documento,
            razon_social=payload.adquirente.razon_social
        ) if payload.adquirente else Adquirente()
        pedido_domain = PedidoLocal(
            id_pedido=payload.id_pedido_origen,
            adquirente=adquirente_dominio,
            items=items_dominio,
            subtotal=payload.subtotal,
            propina_voluntaria=payload.propina_voluntaria,
            impuestos_totales=payload.impuestos_totales,
            total_factura=payload.total_factura,
            metodos_pago=metodos_pago
        )
        
        # Step 3 — Log attempt (PROCESANDO)
        await self.log_repository.save_log(status="PROCESANDO", payload_in=payload.model_dump())
        
        # Steps 4–6 — Call DIAN, update log, return DTO
        try:
            dian_res = await self.dian_port.send_invoice(invoice_data=pedido_domain.model_dump())
            track_id = dian_res.get("track_id", "UNKNOWN")
            cufe = dian_res.get("cufe")
            status = dian_res.get("status", "SUCCESS")
            await self.log_repository.save_log(
                status=status, 
                payload_in=payload.model_dump(), 
                response_out=dian_res
            )
            return PedidoLocalResponse(
                track_id=track_id,
                cufe=cufe,
                status=status,
                message="Factura procesada con éxito."
            )
        except Exception as e:
            await self.log_repository.save_log(
                status="FAILED", 
                payload_in=payload.model_dump(), 
                response_out={"error": str(e)}
            )
            raise e
The six steps in the execute docstring map directly to the sequence diagram in the Data Flow page: validate → map → log PROCESANDO → call DIAN port → log SUCCESS/FAILED → return response DTO.

Dependency Injection

FastAPI’s Depends() system wires the concrete DianSoapAdapter and the use case together at request time. Both provider functions live in the controller module:
def get_dian_adapter() -> DianSoapAdapter:
    """Provee la instancia configurada del Adaptador SOAP de la DIAN."""
    # En producción se decidiría entre WSDL de Habilitación o Producción
    # según un flag de entorno (ej. settings.ENVIRONMENT == 'production')
    wsdl = settings.DIAN_WSDL_URL_HABILITACION if settings.ENVIRONMENT != 'production' else settings.DIAN_WSDL_URL_PRODUCCION
    
    return DianSoapAdapter(
        wsdl_url=wsdl,
        cert_path=str(settings.DIAN_CERT_PATH),
        password=settings.DIAN_CERT_PASSWORD.get_secret_value()
    )

def get_use_case(
    dian_adapter: DianSoapAdapter = Depends(get_dian_adapter)
) -> FacturarPedidoLocalUseCase:
    """
    Inyecta las dependencias al caso de uso.
    Queda pendiente Mock de InvoiceLogRepositoryPort hasta el siguiente Issue de BD.
    """
    class MockLogRepo:
        async def log_attempt(self, **kwargs): pass
        async def update_status(self, **kwargs): pass
        
    return FacturarPedidoLocalUseCase(
        dian_port=dian_adapter,
        log_repo=MockLogRepo()
    )
MockLogRepo inside get_use_case() is a temporary no-op placeholder for the log port while the real PostgreSQL adapter (SQLAlchemy async) is pending implementation. The constructor call passes log_repo=MockLogRepo(), but FacturarPedidoLocalUseCase.__init__ declares the parameter as log_repository — this keyword argument name mismatch will raise a TypeError at runtime and must be corrected to log_repository=MockLogRepo() before the dependency injection chain is exercised. Once the database adapter is complete, MockLogRepo will be replaced with the real IInvoiceLogRepositoryPort implementation.
The route handler receives the fully constructed use case through Depends(get_use_case):
@router.post("/", response_model=PedidoLocalResponse, status_code=status.HTTP_201_CREATED)
async def facturar_pedido_local(
    payload: PedidoLocalCreateRequest,
    use_case: FacturarPedidoLocalUseCase = Depends(get_use_case)
):
    result = await use_case.execute(payload)
    ...

Testing Benefits

Because FacturarPedidoLocalUseCase only holds references to the two port interfaces (not concrete adapters), tests in tests/application/use_cases/test_facturar_pedido_local.py replace both with in-memory mocks:
class MockDianPort:
    async def send_invoice(self, invoice_data: dict) -> dict:
        return {"track_id": "ABC-123", "status": "SUCCESS", "message": "OK"}
        
    async def send_credit_note(self, note_data: dict) -> dict:
        pass


class MockLogRepositoryPort:
    def __init__(self):
        self.logs = []
        self.counter = 1
        
    async def save_log(self, status: str, payload_in: dict, response_out: dict = None) -> int:
        self.logs.append({"id": self.counter, "status": status, "payload_in": payload_in, "response_out": response_out})
        self.counter += 1
        return self.counter - 1


@pytest.mark.asyncio
async def test_facturar_pedido_local_success(mock_dian_port, mock_log_repository, valid_payload):
    use_case = FacturarPedidoLocalUseCase(mock_dian_port, mock_log_repository)
    
    response = await use_case.execute(valid_payload)
    
    assert response.track_id == "ABC-123"
    assert response.status == "SUCCESS"
    assert len(mock_log_repository.logs) == 2  # 1 for PROCESANDO, 1 for SUCCESS


@pytest.mark.asyncio
async def test_facturar_pedido_local_invalid_totals(mock_dian_port, mock_log_repository, valid_payload):
    invalid_payload = valid_payload.model_copy()
    invalid_payload.total_factura = Decimal("9999.00")  # Deliberately wrong
    
    use_case = FacturarPedidoLocalUseCase(mock_dian_port, mock_log_repository)
    
    with pytest.raises(ValueError, match="El total de la factura no coincide"):
        await use_case.execute(invalid_payload)
MockDianPort returns a hardcoded success dict and MockLogRepositoryPort records every save_log call in memory. The success test verifies both the returned track_id and that exactly two log entries were written (one PROCESANDO, one SUCCESS). The invalid-totals test verifies the ValueError is raised before any log or SOAP call. Zero network connections, zero database sessions.

Build docs developers (and LLMs) love