Módulo 5: Patrones para estructurar y adaptar

3. La API de pago desprolija de Boletia como Adapter

Descripción

Al terminar esta lección vas a haber hecho el trabajo completo: tomar el SDK de Zafiro tal como es —con sus tres convenciones de nombre, sus dos unidades para el dinero, su identificador que cambia de llave y sus seis valores de estado— y ponerlo detrás de un PaymentProvider limpio que el resto de Boletia pueda usar sin enterarse de nada. Vas a ver el antes y el después de los cinco archivos afectados, vas a medir qué se gana en los tres frentes —desorden contenido, pruebas sin red, cambio de proveedor local— y vas a salir con una plantilla para hacer esto sobre cualquier dependencia externa.

Y vas a ver el límite del patrón. Un adaptador que traduce unidades, normaliza estados y convierte errores se paga solo; uno que solo renombra métodos es un archivo más, un salto más y cero ganancia. La diferencia no es de forma sino de contenido, y saber distinguirlos es la mitad del criterio de este módulo.

Esto importa porque es, probablemente, el refactor que más veces vas a hacer en tu carrera. Cada empresa integra proveedores de pago, de envío, de facturación, de mensajería. Y en todas ocurre lo mismo: las primeras integraciones se escriben directas, porque hay prisa y porque parece exagerado envolver algo que se usa en un lugar; para cuando se usa en cinco, ya nadie recuerda dónde estaban todos.

Conexión con el módulo: la lección 1 te mostró el problema —el SDK desparramado en cinco archivos, con tres normalizaciones del mismo estado y un error que cuesta dinero— y la lección 2 te dio la herramienta con su anatomía de cuatro piezas y la regla del contrato diseñado desde tu necesidad. Esta lección junta las dos cosas y hace el trabajo. La lección 4 pasa a Facade, que resuelve el otro problema del contacto con lo complicado: no traducir, sino simplificar. La lección 5 vuelve sobre este adaptador para agregarle reintento con un Decorator, sin modificarlo. Y la lección 8 —el proyecto— te pide terminar el trabajo que aquí dejamos a medias y justificar dónde pusiste la frontera.

La aduana

Cuando un contenedor llega a un puerto, no entra al país tal cual. Pasa por la aduana.

En la aduana ocurren cuatro cosas, siempre las mismas. Se declara qué viene: no se confía en la etiqueta de origen, se abre y se verifica. Se convierte: el valor llega en la moneda de origen y se registra en la local. Se traduce la clasificación: lo que allá es "producto textil categoría 4" aquí es una partida arancelaria con otro número. Y se rechaza lo que no puede entrar, con un documento que explica por qué.

Después de la aduana, el contenedor circula por el país como cualquier otro. El camionero que lo lleva a la bodega no sabe de qué país vino, ni en qué moneda estaba valuado, ni cómo lo clasificaba la aduana de origen. Esa ignorancia es el producto de la aduana: todo lo que entra al país entra ya traducido al vocabulario del país.

Y fíjate en la propiedad que la hace útil: está en un punto. Un país que dejara entrar contenedores por cualquier playa, cada uno con su propio inspector improvisado, no tendría una aduana peor —no tendría aduana—. El valor no está en el procedimiento, está en que sea el único paso posible.

Eso es lo que le falta a Boletia hoy: el SDK de Zafiro entra por cinco playas distintas y cada una lo inspecciona a su manera. Vamos a construir la aduana.

Ejemplo trabajado: de cinco playas a una aduana

Este es el caso completo del módulo. Lo vamos a hacer en cinco pasos, y cada paso responde a una pregunta que te vas a tener que hacer cada vez que aísles una dependencia.

El antes, con todo el detalle

Antes de mover nada, el mapa. El primero de los cinco lugares es app.py, que al arrancar llama a zafiropay.configure(apiKey=..., env=...) —el SDK guarda la credencial en un estado global y hay que hacerlo una vez, antes de que cualquier cliente funcione—. Los otros cuatro:

# ── Archivo 2: checkout/checkout.py ────────────────────────────────────────
import zafiropay

def charge_with_zafiro(order):
    client = zafiropay.ZafiroClient()

    # El monto va como texto con dos decimales. order.total es un float.
    res = client.doCharge(
        amount=f"{order.total:.2f}",
        currency=order.currency,
        ref=f"BOL-{order.id}",
        idem_key=f"boletia-order-{order.id}-attempt-{order.attempts}",
    )

    # Normalización del estado, versión 1 de 3.
    if res.get("status", "").upper().startswith("APPROV"):
        order.status = "paid"
    elif res.get("status", "").lower() == "pending":
        order.status = "pending"
    else:
        order.status = "failed"

    return res
# ── Archivo 3: api/routes.py ───────────────────────────────────────────────
def post_checkout(request):
    order = build_order(request)
    res = charge_with_zafiro(order)

    # La llave del identificador cambia según el medio de pago.
    txn_id = res.get("id") or res.get("transaction_id")
    if res.get("ok"):
        return response(200, {"order_id": order.id, "transaction": txn_id})

    # reason_code es un entero sin texto. Este mapa se escribió a mano
    # leyendo un PDF del proveedor, y está incompleto.
    if res.get("reason_code") == 51:
        return response(402, {"error": "Fondos insuficientes"})
    return response(402, {"error": "El pago fue rechazado"})


# ── Archivo 4: admin/refunds.py ────────────────────────────────────────────
import zafiropay

def refund_order(order, amount: float, operator_id: int):
    client = zafiropay.ZafiroClient()

    # Aquí el monto va en CENTAVOS ENTEROS. Distinta unidad que al cobrar.
    res = client.refund_payment(transaction=order.transaction_id, cents=int(amount * 100))

    # Normalización del estado, versión 2 de 3. Y esta tiene un error.
    status = (res.get("status") or "").lower()
    if status in ("approved", "refunded"):
        return Refund(status="done", operator_id=operator_id)
    return Refund(status="failed", operator_id=operator_id)
    # ← "pending_review" cae aquí y se marca como fallido. No lo es.

El archivo 5, jobs/reconcile.py, corre de madrugada, llama a client.Status(txn_id=...) y trae la tercera normalización del estado —la de la lección 1: cubre los seis valores conocidos, y ante uno nuevo no entra en ninguna rama y no avisa—. Más tests/conftest.py, que pone ZAFIRO_OFFLINE=1 para que la suite no dependa del servidor del proveedor al importar.

Detente un momento en el int(amount * 100) del archivo 4, porque se ve inocente y no lo es. En punto flotante 19.99 * 100 no da 1999: da 1998.9999999999998. El int() trunca hacia abajo, así que ese reembolso devuelve un centavo de menos. Con una operación no se nota; con cien mil al año, sí. Ese error existe hoy en Boletia y nadie lo ha visto, porque está escondido en un archivo que nadie lee.

Paso 1 — Escribe la llamada que te gustaría poder hacer

Antes de mirar el SDK, mira tu necesidad. ¿Qué querría escribir el checkout si pudiera pedir lo que quisiera?

# Lo que Boletia QUIERE poder escribir. Sin mirar el SDK.

result = provider.charge(order)

if result.is_succeeded:
    order.mark_paid(result.transaction_id)
elif result.is_pending:
    order.mark_pending(result.transaction_id)
    show_payment_instructions(result.instructions)
else:
    order.mark_failed(result.decline_reason)

Léelo con cuidado, porque en esas ocho líneas ya está decidido casi todo. Aparecen tres estados —éxito, pendiente, rechazo— y no seis. Aparece un solo nombre para el identificador. Aparece un motivo de rechazo que se puede mostrar. Y no aparece por ningún lado idem_key, ni env, ni ok: esos son conceptos de Zafiro, y Boletia no tiene por qué conocerlos. Este paso parece trivial y es el que más se salta. Si empiezas por el SDK, terminas con el SDK.

Paso 2 — Deriva el contrato y los tipos propios

De esa llamada sale todo lo demás:

# Archivo: payments/provider.py — el contrato de Boletia. Esto es NUESTRO.

from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from typing import Protocol


class PaymentStatus(Enum):
    """Los tres estados que a Boletia le importan. Ni uno más.

    Zafiro maneja seis valores de texto; otros proveedores manejarán otros.
    Traducir de N valores ajenos a estos tres es trabajo del adaptador.
    """
    SUCCEEDED = "succeeded"
    PENDING = "pending"
    DECLINED = "declined"


@dataclass(frozen=True)
class PaymentResult:
    """El resultado de un cobro, en el vocabulario de Boletia.

    Es frozen a propósito: nadie debería poder modificar el resultado de un
    cobro después de recibirlo.
    """
    status: PaymentStatus
    transaction_id: str
    instructions: dict | None = None   # cómo pagar, cuando queda pendiente
    decline_reason: str | None = None  # texto para mostrar, cuando se rechaza

    @property
    def is_succeeded(self) -> bool:
        return self.status is PaymentStatus.SUCCEEDED

    @property
    def is_pending(self) -> bool:
        return self.status is PaymentStatus.PENDING


@dataclass(frozen=True)
class RefundResult:
    status: PaymentStatus
    refund_id: str


class PaymentProvider(Protocol):
    """Todo proveedor de pago de Boletia sabe hacer estas tres cosas."""

    def charge(self, order) -> PaymentResult: ...

    def refund(self, order, amount: Decimal) -> RefundResult: ...

    def status_of(self, transaction_id: str) -> PaymentStatus: ...

Y los errores, que son parte del contrato aunque casi nunca se declaren:

# Archivo: payments/errors.py

class PaymentError(Exception):
    """Raíz de todos los errores de pago. Existe para que quien llame pueda
    escribir un solo except sin conocer los tipos de ningún proveedor."""


class PaymentUnavailable(PaymentError):
    """El proveedor no respondió: red caída, timeout, error 5xx.
    Distinguirlo importa porque este SÍ se puede reintentar (lección 5)."""


class PaymentRejected(PaymentError):
    """El proveedor respondió y dijo que no. Reintentar no sirve de nada."""

    def __init__(self, reason: str, code: str | None = None):
        self.reason, self.code = reason, code
        super().__init__(reason)


class PaymentMisconfigured(PaymentError):
    """Falta una credencial o está mal. Es un error nuestro, no del cliente."""

Fíjate en la decisión más importante de todo el contrato: un rechazo no lanza excepción, devuelve un PaymentResult con estado DECLINED. Un rechazo es un resultado normal del negocio —el banco dijo que no— y merece tratarse en el flujo, no en un except. En cambio, un proveedor que no responde sí es una excepción, porque no hay resultado que devolver. Esa línea entre "resultado" y "excepción" es tuya y hay que trazarla a propósito; Zafiro no la traza —a veces devuelve ok=False y a veces lanza ZafiroError para el mismo rechazo— y por eso hoy todo el que lo llama tiene que hacer las dos cosas.

Un detalle honesto sobre Decimal: el contrato lo pide, pero Order.total es un float desde el módulo 1. Es una deuda real del modelo y este es el momento en que se hace visible, porque la conversión que va a hacer el adaptador es justo donde vivía el error del centavo. Poner una frontera saca a la luz las decisiones dudosas que había a ambos lados.

Paso 3 — Escribe el adaptador

Ahora sí, con el contrato ya decidido, se mira el SDK. Todo lo que sigue vive en un solo archivo.

# Archivo: payments/zafiro_provider.py
# El ÚNICO archivo del sistema que importa zafiropay. Si aparece en otro, hay
# un agujero en la frontera.

import logging, os
from decimal import Decimal, ROUND_HALF_UP

# Apagar la consulta de versión al importar. El SDK sale a internet al ser
# importado, y eso ataba TODAS las pruebas de Boletia a su servidor.
# Va antes del import porque el SDK lo lee en ese momento.
os.environ.setdefault("ZAFIRO_OFFLINE", "1")

import requests.exceptions          # el SDK filtra los errores de su librería HTTP
import zafiropay

from payments.provider import PaymentResult, PaymentStatus, RefundResult
from payments.errors import PaymentMisconfigured, PaymentRejected, PaymentUnavailable

log = logging.getLogger(__name__)


# Zafiro usa seis valores para tres estados. Esta tabla es la única versión
# de la verdad; antes vivía repetida y distinta en tres archivos.
_STATUS = {
    "approved": PaymentStatus.SUCCEEDED,
    "pending": PaymentStatus.PENDING,
    "pending_review": PaymentStatus.PENDING,
    "declined": PaymentStatus.DECLINED,
    "rejected": PaymentStatus.DECLINED,
    "refunded": PaymentStatus.SUCCEEDED,
}

# Los motivos de rechazo llegan como enteros. Esta tabla salió del PDF del
# proveedor; lo que no esté aquí se muestra con un texto genérico.
_DECLINE_REASONS = {
    51: "Fondos insuficientes",
    54: "Tarjeta vencida",
    61: "Excede el límite de la tarjeta",
}


class ZafiroProvider:
    """Adapta el SDK zafiropay al contrato PaymentProvider de Boletia."""

    def __init__(self, client=None, *, api_key: str = "", env: str = "live"):
        # El SDK guarda la credencial en un estado global. Lo llamamos AQUÍ,
        # en el único lugar que sabe que ese estado existe, en vez de en app.py.
        if client is None:
            if not api_key:
                raise PaymentMisconfigured("Falta ZAFIRO_KEY")
            zafiropay.configure(apiKey=api_key, env=env)
            client = zafiropay.ZafiroClient()
        # Recibirlo por parámetro es lo que permite pasar uno falso en pruebas.
        self._client = client

    # ── El contrato ───────────────────────────────────────────────────────

    def charge(self, order) -> PaymentResult:
        raw = self._call(
            self._client.doCharge,
            amount=self._as_amount_string(order.total),
            currency=order.currency,
            ref=f"BOL-{order.id}",
            idem_key=self._idempotency_key(order),
        )
        return self._to_payment_result(raw)

    def refund(self, order, amount: Decimal) -> RefundResult:
        raw = self._call(
            self._client.refund_payment,
            transaction=order.transaction_id,
            cents=self._as_cents(amount),      # otra unidad. Sí, en el mismo SDK.
        )
        return RefundResult(
            status=self._to_status(raw),
            refund_id=self._id_of(raw),
        )

    def status_of(self, transaction_id: str) -> PaymentStatus:
        raw = self._call(self._client.Status, txn_id=transaction_id)
        return self._to_status(raw)

    # ── Traducción del dinero ─────────────────────────────────────────────

    @staticmethod
    def _as_amount_string(total) -> str:
        """Al cobrar, Zafiro quiere el monto como texto con dos decimales.

        Pasamos por Decimal aunque order.total sea float: f"{19.99:.2f}" redondea
        con las reglas del punto flotante, y aquí queremos las de una factura.
        """
        return str(Decimal(str(total)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))

    @staticmethod
    def _as_cents(amount: Decimal) -> int:
        """Y al reembolsar, en CENTAVOS ENTEROS. Distinta unidad, mismo SDK.

        int(amount * 100) sobre un float da un centavo de menos con 19.99,
        porque 19.99 * 100 == 1998.9999999999998 e int() trunca hacia abajo.
        """
        cents = (Decimal(amount) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
        return int(cents)

    # ── Traducción de la respuesta ────────────────────────────────────────

    @staticmethod
    def _id_of(raw: dict) -> str:
        """El identificador se llama "id" con tarjeta y "transaction_id" con
        transferencia. Este método es el único lugar que tiene que saberlo."""
        txn_id = raw.get("id") or raw.get("transaction_id")
        if not txn_id:
            raise PaymentUnavailable(f"Zafiro respondió sin identificador: {raw!r}")
        return txn_id

    @staticmethod
    def _to_status(raw: dict) -> PaymentStatus:
        """Seis valores en dos capitalizaciones → tres estados nuestros."""
        status = _STATUS.get(str(raw.get("status", "")).lower())
        if status is None:
            # Un valor desconocido NO se ignora ni se asume: se avisa y se trata
            # como pendiente, el único estado seguro (no cobra ni cancela).
            log.error("Zafiro devolvió un estado desconocido: %r", raw.get("status"))
            return PaymentStatus.PENDING
        return status

    def _to_payment_result(self, raw: dict) -> PaymentResult:
        status = self._to_status(raw)

        if status is PaymentStatus.DECLINED:
            # Un rechazo puede venir sin identificador, así que no exigimos uno.
            reason = _DECLINE_REASONS.get(raw.get("reason_code"), "El pago fue rechazado")
            txn_id = raw.get("id") or raw.get("transaction_id") or ""
            return PaymentResult(status, txn_id, decline_reason=reason)

        return PaymentResult(status, self._id_of(raw), instructions=raw.get("instructions"))

    # ── Traducción de los errores ─────────────────────────────────────────

    def _call(self, method, **kwargs) -> dict:
        """Llama al SDK y convierte sus tres formas de fallar a las nuestras."""
        try:
            raw = method(**kwargs)
        except zafiropay.ZafiroError as err:
            # .code a veces es texto y a veces entero; el mensaje está en
            # .message o en .detail según qué parte del SDK lo haya lanzado.
            message = getattr(err, "message", None) or getattr(err, "detail", str(err))
            raise PaymentRejected(message, code=str(getattr(err, "code", ""))) from err
        except requests.exceptions.RequestException as err:
            # Atrapamos el error de su librería HTTP aquí para que nadie más
            # en Boletia tenga que importar requests.
            raise PaymentUnavailable("Zafiro no respondió") from err
        except RuntimeError as err:
            # Es lo que lanza si nadie llamó a configure(). Mensaje inútil.
            if "not configured" in str(err).lower():
                raise PaymentMisconfigured("Zafiro no está configurado") from err
            raise

        if not isinstance(raw, dict):
            raise PaymentUnavailable(f"Zafiro devolvió algo inesperado: {raw!r}")
        return raw

    @staticmethod
    def _idempotency_key(order) -> str:
        """Zafiro ignora en SILENCIO las llaves de más de 32 caracteres, y una
        llave ignorada significa que un reintento cobra dos veces. La recortamos
        nosotros a un valor corto y estable en vez de confiar."""
        return f"bol-{order.id}-{order.attempts}"[:32]

Ese archivo es feo: tablas de traducción, un os.environ antes de un import, tres except para el mismo problema y comentarios explicando rarezas ajenas. Y así debe ser. Es la aduana.

Paso 4 — Conecta el punto de construcción

La cuarta pieza de la anatomía. El adaptador se registra en la factory que armaste en el módulo 4, y ahí termina su presencia en el sistema:

# Archivo: payments/factory.py

def get_payment_provider(name: str) -> PaymentProvider:
    if name == "stripe":
        return StripeProvider(api_key=settings.STRIPE_KEY)
    ...
    if name == "zafiro":
        return ZafiroProvider(api_key=settings.ZAFIRO_KEY, env=settings.ZAFIRO_ENV)
    raise UnknownProviderError(name)

Una línea. Ese es el momento en que Zafiro se vuelve, para el resto de Boletia, un proveedor más entre cuatro.

Paso 5 — El después de los cinco archivos

De app.py desaparece la llamada a zafiropay.configure(): ese estado global ahora vive dentro del adaptador, que es el único que tiene por qué saber que existe. Y tests/conftest.py pierde su línea de ZAFIRO_OFFLINE, porque las pruebas ya no cargan el SDK. Los otros tres quedan así:

# ── checkout/checkout.py ──────────────────────────────────────────────────
def charge_order(order) -> PaymentResult:
    provider = get_payment_provider(order.provider)
    result = provider.charge(order)

    if result.is_succeeded:
        order.mark_paid(result.transaction_id)
    elif result.is_pending:
        order.mark_pending(result.transaction_id)
    else:
        order.mark_failed(result.decline_reason)

    return result


# ── api/routes.py ─────────────────────────────────────────────────────────
def post_checkout(request):
    order = build_order(request)
    try:
        result = charge_order(order)
    except PaymentUnavailable:
        return response(503, {"error": "El pago no está disponible ahora"})

    if result.status is PaymentStatus.DECLINED:
        return response(402, {"error": result.decline_reason})

    return response(200, {"order_id": order.id, "transaction": result.transaction_id,
                          "instructions": result.instructions})


# ── admin/refunds.py ──────────────────────────────────────────────────────
def refund_order(order, amount: Decimal, operator_id: int) -> Refund:
    provider = get_payment_provider(order.provider)
    result = provider.refund(order, amount)
    # Un solo estado que interpretar, y "en revisión" ya llega como PENDING.
    return Refund(status=result.status, refund_id=result.refund_id, operator_id=operator_id)


# ── jobs/reconcile.py ─────────────────────────────────────────────────────
def reconcile_pending_orders():
    for order in Order.pending_older_than(hours=2):
        provider = get_payment_provider(order.provider)
        status = provider.status_of(order.transaction_id)

        if status is PaymentStatus.SUCCEEDED:
            mark_paid(order.id)
        elif status is PaymentStatus.DECLINED:
            mark_failed(order.id)
        # PENDING: se revisa mañana. Y ya no hay "valor desconocido" posible,
        # porque el enum tiene exactamente tres miembros.

Qué esperar de este refactor. Cuatro cosas concretas, y después una advertencia.

Uno: el desorden quedó contenido, y con él tres errores. La normalización del estado pasó de tres versiones distintas a una tabla. Con eso desapareció el error de admin/refunds.py que trataba pending_review como fallo —el que hacía que un operador reintentara un reembolso ya en curso y el cliente cobrara dos veces—, desapareció el silencio de reconcile.py ante un estado nuevo, y desapareció el centavo perdido del int(amount * 100). Ninguno se arregló a propósito: se arreglaron porque juntar la traducción en un lugar con nombre te obliga a mirarla completa.

Dos: las pruebas dejaron de necesitar red. Y esto merece verse:

# Archivo: tests/fakes.py

class FakeProvider:
    """Proveedor de pago para pruebas. No sale a la red y no tiene sorpresas."""

    def __init__(self, result=None, fail_with=None):
        self.result = result or PaymentResult(PaymentStatus.SUCCEEDED, "fake_txn_1")
        self.fail_with = fail_with
        self.charges = []                # para verificar qué se le pidió

    def charge(self, order):
        self.charges.append(order)
        if self.fail_with:
            raise self.fail_with
        return self.result

    def refund(self, order, amount):
        return RefundResult(PaymentStatus.SUCCEEDED, "fake_refund_1")

    def status_of(self, transaction_id):
        return self.result.status
# Archivo: tests/test_checkout.py

def test_pending_order_is_not_marked_as_paid():
    provider = FakeProvider(
        result=PaymentResult(PaymentStatus.PENDING, "txn_9", instructions={"bank": "..."})
    )
    order = make_order(total=1250.00)

    charge_order_with(provider, order)

    assert order.status == "pending"
    assert order.transaction_id == "txn_9"

Esa prueba corre en milisegundos, no toca internet y verifica una regla de negocio. Antes era imposible sin parchear el módulo zafiropay, y por eso no existía. Las pruebas del adaptador son otra cosa: ahora se escriben contra respuestas reales guardadas, que es la única forma honesta de probar una traducción.

# Archivo: tests/test_zafiro_provider.py
# Respuestas REALES capturadas del sandbox del proveedor, guardadas como datos.

TRANSFER_PENDING = {"ok": True, "transaction_id": "zf_01HY", "status": "pending",
                    "instructions": {"bank": "Bancolombia", "account": "..."}}

DECLINED = {"ok": False, "status": "DECLINED", "reason_code": 51}


class StubClient:
    def __init__(self, response): self.response = response
    def doCharge(self, **kw): return self.response


def test_transfer_pending_reads_the_other_id_key():
    """El caso que rompía api/routes.py: la llave se llama distinto."""
    provider = ZafiroProvider(client=StubClient(TRANSFER_PENDING))
    result = provider.charge(make_order())
    assert result.transaction_id == "zf_01HY"
    assert result.instructions["bank"] == "Bancolombia"


def test_declined_returns_a_result_and_does_not_raise():
    """Un rechazo es un resultado de negocio, no una excepción."""
    provider = ZafiroProvider(client=StubClient(DECLINED))
    result = provider.charge(make_order())
    assert result.status is PaymentStatus.DECLINED
    assert result.decline_reason == "Fondos insuficientes"

Y el error del centavo, convertido en prueba para que no vuelva: se llama refund con Decimal("19.99") y se verifica que al SDK le llegó cents=1999, no 1998.

Tres: cambiar de proveedor se volvió local. El ejercicio 3 de la lección 1 te pidió estimar el costo de la versión 3.0 de Zafiro: cinco archivos y ninguna forma de verificar. Ahora es un archivo, con pruebas contra respuestas guardadas. Cuatro: apareció un lugar donde poner cosas. Como todas las llamadas al proveedor pasan por una clase con tres métodos, agregar reintento, medición de latencia o auditoría es envolver ese objeto una vez. Eso es la lección 5.

Y la advertencia. Este refactor no fue gratis. Boletia ganó tres archivos y el checkout ahora depende de un contrato propio que alguien tiene que mantener: si mañana el PaymentResult necesita un campo más, hay que tocarlo y revisar los cuatro proveedores. Esa es la factura de la frontera y se paga siempre. Lo que la justifica aquí son datos concretos: cinco lugares de uso, tres traducciones divergentes con un error de dinero entre ellas, pruebas imposibles de escribir y un proveedor que ya anunció una versión mayor. Con menos que eso, la cuenta puede no dar.

El límite: el adaptador que solo renombra

Ahora la parte incómoda, porque el mismo patrón que acaba de pagarse solo puede ser puro peso muerto.

Imagina que Zafiro, en vez del SDK que viste, publicara uno bien diseñado: charge(amount: Decimal, currency, reference), refund(transaction_id, amount: Decimal), get_status(transaction_id). Mismos conceptos que Boletia, mismas unidades, errores tipados. El adaptador que le corresponde sería este:

# ⚠️ Un adaptador que no adapta nada.

class ZafiroProvider:
    def __init__(self, client):
        self._client = client

    def charge(self, order):
        return self._client.charge(
            amount=order.total, currency=order.currency, reference=f"BOL-{order.id}"
        )

    def refund(self, order, amount):
        return self._client.refund(transaction_id=order.transaction_id, amount=amount)

    def status_of(self, transaction_id):
        return self._client.get_status(transaction_id)

Tres métodos que reenvían. Cero conversiones de unidad, cero normalización de estados, cero traducción de errores. La única diferencia con llamar al cliente directo es un nombre y un salto de lectura más.

¿Vale la pena? La respuesta honesta es depende, y en la mayoría de los casos no. La pregunta que decide no es "¿se ve más limpio?" sino esta: ¿qué te compra este archivo que no tendrías sin él?

Dos respuestas sí lo justifican. La costura para probar: si el resto del sistema depende de PaymentProvider en vez de ZafiroClient, puedes sustituirlo por un falso sin parchear módulos, y eso vale por sí solo aunque el adaptador no traduzca nada. Y una familia real que uniformar: si Boletia ya tiene cuatro pasarelas que deben verse iguales desde el checkout, el contrato común es la razón de que el checkout sea legible.

Y tres respuestas no lo justifican, aunque son las que más se dan. "Es buena práctica no depender de librerías externas": dicha sin un costo concreto que evitar, es una regla aprendida de memoria. "Por si algún día cambiamos de proveedor": es el YAGNI del módulo 2, con una ironía adicional —la interfaz que diseñes hoy va a tener la forma del único proveedor que tienes a la vista, así que el día del cambio tampoco va a encajar—. "Se ve más ordenado": la incomodidad estética no es un costo.

La regla de bolsillo, entonces: un adaptador se paga con lo que traduce, no con lo que renombra. Cuenta las conversiones reales que hace el tuyo —unidades, formatos, vocabularios, tipos de error, valores ausentes—. Si la lista está vacía y no hay ni costura de pruebas ni familia que uniformar, tienes tres métodos de reenvío y un salto de lectura. Eso es lo que la lección 7 va a llamar, sin rodeos, escribir una función.

Errores comunes

Diseñar el contrato con un solo proveedor a la vista (de criterio). Qué pasa: alguien escribe PaymentProvider mirando únicamente a Zafiro, y el contrato queda con la forma de Zafiro. Meses después entra un proveedor que confirma por webhook y el contrato no le queda: se agregan campos opcionales, después un isinstance, y en un año el contrato es la unión de todos los proveedores en vez de la intersección de lo que Boletia necesita. Por qué pasa: es imposible diseñar una buena abstracción con un solo ejemplo. Cómo detectarlo: descríbele el contrato a alguien como si fuera para un proveedor cualquiera; si tienes que explicar por qué existe algún método —"ese lo tiene porque Zafiro funciona así"—, ese método no pertenece. Cómo corregirlo: escribe el contrato mirando lo que Boletia hace, no lo que el proveedor ofrece, y cuando entre el segundo prepárate para cambiarlo. Es la regla de tres del módulo 2 aplicada a interfaces.

Dejar que el dict crudo se escape en algún camino (de implementación). Qué pasa: el adaptador traduce bien el camino feliz y en alguno secundario devuelve el diccionario del SDK tal cual. Como funciona, nadie lo nota, y meses después hay un archivo que hace result["authorization_code"] sobre algo que salió del adaptador: la frontera tiene un agujero exactamente donde nadie mira. Por qué pasa: los caminos secundarios se escriben con menos cuidado y se prueban menos. Cómo detectarlo: revisa el tipo de retorno declarado de cada método público; si alguno dice dict, Any o nada, ahí está. Cómo corregirlo: declara los tipos de retorno y actívalos con un verificador estático. Es la única forma que no depende de que alguien se acuerde.

Poner la frontera demasiado abajo (de criterio). Qué pasa: alguien envuelve el SDK con un adaptador que refleja sus tres métodos con nombres limpios y da el trabajo por hecho. La frontera existe, pero está al nivel del SDK y no al del negocio: el checkout sigue teniendo que orquestar —pedir el cobro, revisar el estado, decidir qué hacer con las instrucciones, agendar la reconciliación— y esa orquestación se repite en cada llamador. Por qué pasa: la frontera al nivel del SDK es la fácil de encontrar, porque el SDK te dice dónde está; la del negocio hay que decidirla. Cómo detectarlo: si tres archivos hacen la misma secuencia de tres llamadas al adaptador, falta una frontera más arriba. Cómo corregirlo: eso es un Facade, y es la lección 4. Las dos fronteras no compiten —el Adapter traduce el idioma del proveedor, el Facade simplifica la secuencia— y un sistema maduro suele tener las dos.

Ejercicios

Ejercicio 1 — Encuentra qué se perdió en la traducción. El adaptador que escribimos descarta información que el SDK sí devolvía. Enumera al menos tres datos de las respuestas de Zafiro que ya no llegan al resto de Boletia. Para cada uno, decide si está bien descartarlo, y si no, dónde lo agregarías.

Ver solución

authorization_code. Viene solo en los pagos aprobados con tarjeta. Es el código que emite el banco y el que un cliente cita cuando reclama un cargo. No está bien descartarlo: atención a clientes lo va a necesitar. Va como campo opcional en PaymentResult, con ese mismo nombre —es un término del dominio de pagos, no una rareza de Zafiro—.

El amount de la respuesta. Zafiro devuelve el monto que efectivamente cobró. Descartarlo parece razonable porque Boletia ya sabe cuánto pidió… hasta el día en que no coincidan, por un redondeo o una comisión. Vale la pena conservarlo y compararlo: es el tipo de verificación que solo se puede escribir cuando existe un punto único.

El reason_code numérico. El adaptador lo traduce a un texto y tira el número. Para mostrárselo al cliente, el texto es lo correcto; para las métricas —¿cuántos rechazos por fondos insuficientes tuvimos este mes?— el número es mejor, porque es estable y no depende de cómo se redacte el mensaje. Conviene conservar los dos. El ok, en cambio, sí está bien descartarlo: es redundante con el estado y además mentía —ok=True con estado pendiente significa "la petición salió bien", no "el pago salió bien"—.

Por qué funciona: todo adaptador descarta información, y ese descarte es una decisión de diseño, no un detalle. La pregunta correcta no es "¿cómo paso todo?" —eso sería devolver el dict y no adaptar nada— sino "¿qué necesita Boletia de verdad?". Hacer esa pregunta explícita es lo que separa un contrato pensado de uno copiado.

Ejercicio 2 — Escribe el adaptador de un segundo proveedor. Boletia integra una quinta pasarela, NubePay, para el mercado chileno. Su SDK es distinto y, curiosamente, más limpio:

# Paquete: nubepay 4.0 — externo.

class NubeClient:
    def __init__(self, token: str, sandbox: bool = False): ...
    def create_payment(self, amount_cents: int, currency: str, metadata: dict) -> "Payment": ...
    def get_payment(self, payment_id: str) -> "Payment": ...
    def create_refund(self, payment_id: str, amount_cents: int) -> "Refund": ...

class Payment:
    id: str
    state: str          # "processing" | "paid" | "failed" | "expired"
    failure_message: str | None
    checkout_url: str | None   # si el cliente debe completar el pago en su sitio

Escribe NubePayProvider cumpliendo el mismo PaymentProvider que ya definimos. Presta atención a tres decisiones: qué hacer con "expired", qué hacer con checkout_url y qué hacer con metadata.

Ver solución
# Archivo: payments/nubepay_provider.py

# Cuatro estados ajenos → tres nuestros. "expired" y "failed" son, para
# Boletia, la misma cosa: el pago no ocurrió y no va a ocurrir.
_STATE = {
    "processing": PaymentStatus.PENDING,
    "paid": PaymentStatus.SUCCEEDED,
    "failed": PaymentStatus.DECLINED,
    "expired": PaymentStatus.DECLINED,
}


class NubePayProvider:
    """Adapta el SDK nubepay al contrato PaymentProvider de Boletia."""

    def __init__(self, client=None, *, token: str = "", sandbox: bool = False):
        self._client = client or nubepay.NubeClient(token=token, sandbox=sandbox)

    def charge(self, order) -> PaymentResult:
        payment = self._call(
            self._client.create_payment,
            amount_cents=self._as_cents(order.total),
            currency=order.currency,
            metadata={"order_id": order.id, "event_id": order.event_id},
        )
        return self._to_result(payment)

    # status_of() y refund() son análogos: get_payment(payment_id=...) y
    # create_refund(payment_id=..., amount_cents=...), pasando por _call.

    @staticmethod
    def _to_result(payment) -> PaymentResult:
        status = _STATE.get(payment.state, PaymentStatus.PENDING)

        # checkout_url es el equivalente a las "instructions" de Zafiro: lo que
        # el cliente tiene que hacer para completar el pago. Distinta forma,
        # mismo concepto, así que se traduce al MISMO campo del contrato.
        instructions = {"checkout_url": payment.checkout_url} if payment.checkout_url else None

        return PaymentResult(
            status=status,
            transaction_id=payment.id,
            instructions=instructions,
            decline_reason=payment.failure_message if status is PaymentStatus.DECLINED else None,
        )

    def _call(self, method, **kwargs):
        try:
            return method(**kwargs)
        except nubepay.NubeError as err:
            raise PaymentUnavailable("NubePay no respondió") from err

Las tres decisiones:

"expired" se traduce a DECLINED. Boletia solo distingue tres cosas —cobró, está esperando, no cobró— y un pago expirado cae en la tercera. La tentación de agregar un cuarto miembro EXPIRED al enum hay que resistirla: el enum representa lo que Boletia necesita distinguir, no lo que los proveedores reportan.

checkout_url va al campo instructions. Es lo mismo que las instrucciones bancarias de Zafiro con otra forma: "qué tiene que hacer el cliente ahora". Que se traduzca al mismo campo es lo que valida el contrato; si hubieras necesitado un campo checkout_url aparte, sería señal de que el contrato tiene forma de Zafiro.

metadata no es del contrato. Es un parámetro del SDK para rastrear las operaciones desde el panel del proveedor. Lo rellena el adaptador con lo que sabe, y nadie fuera se entera: un buen ejemplo de algo que entra al adaptador desde el dominio sin ser parte del contrato.

Y lo que hay que notar al terminar: este segundo adaptador no cambió una línea del checkout, de las rutas ni del job de conciliación. Eso es lo que la frontera compró.

Ejercicio 3 — Decide si estos tres adaptadores se ganan su lugar. Para cada uno, responde sí o no usando la regla de la lección —un adaptador se paga con lo que traduce, no con lo que renombra— y enumera sus traducciones reales.

(a) Boletia envuelve smtplib en una clase EmailChannel con send(customer, message). Por dentro convierte el Customer en direcciones, arma el mensaje MIME, maneja los errores de SMTP y decide el remitente según el evento. Se usa en cuatro lugares. (b) El equipo envuelve uuid en una clase IdGenerator con un método new_id() que hace return str(uuid.uuid4()). Se usa en nueve lugares. (c) Boletia envuelve el cliente de su base de datos en un OrderRepository con get(order_id), save(order) y pending_older_than(hours). Por dentro escribe SQL y convierte filas en objetos Order. Se usa en once lugares.

Ver solución

(a) Sí, claramente. Traduce mucho: un Customer a direcciones de correo, un texto plano a una estructura MIME con sus cabeceras, y los errores de smtplib a errores propios. Además hay cuatro lugares de uso y la familia de canales —email, SMS, push— necesita un contrato común. (Ojo: "decide el remitente según el evento" suena a lógica de negocio dentro del adaptador, que es el error común de la lección 2.)

(b) No. Cero traducciones. uuid.uuid4() ya devuelve lo que hace falta y str() no es una traducción, es una llamada. La API lleva décadas sin cambiar. Los nueve lugares de uso suenan a argumento, pero fíjate en qué son: nueve llamadas idénticas. Si molesta la repetición, la respuesta es una función de una línea, no una clase. Hay un solo caso en que se justificaría: si necesitaras ids predecibles en las pruebas, y aun ahí inyectar una función generadora es más simple.

(c) Sí, y por una razón distinta a (a). Traduce bastante —filas a objetos, objetos a SQL, errores de la base a errores propios— pero lo decisivo es que sin esta clase habría once archivos escribiendo SQL, o sea lógica de consulta repartida por todo el sistema. Este patrón tiene su propio nombre —Repository— y no está en el catálogo GoF, pero su mecánica es la de esta lección. Vale la pena reconocer la forma: la mayoría de las capas que un sistema tiene entre él y el mundo son adaptadores con otro nombre.

Por qué funciona: los tres tienen la misma forma —una clase propia envolviendo algo ajeno— y solo dos se ganan su lugar. La diferencia no se ve en el diagrama; se ve al contar las traducciones. Ese es el ejercicio que conviene hacer cada vez que envuelvas algo: enumera lo que traduce. Si la lista está vacía, ya sabes la respuesta.

Resumen y siguiente paso

En esta lección hiciste el trabajo completo. Tomaste el SDK de Zafiro tal como es y lo pusiste detrás de un PaymentProvider limpio, en cinco pasos que sirven como plantilla para cualquier dependencia externa: escribe la llamada que te gustaría hacer, deriva el contrato y los tipos propios, escribe el adaptador con toda la traducción adentro, conecta el punto de construcción, y migra a los llamadores.

El adaptador terminó siendo el archivo más feo del proyecto —tablas de traducción, un os.environ antes de un import, tres except para el mismo problema— y eso es lo que debía pasar: es la aduana, donde la incomodidad del exterior se declara, se convierte y se queda.

Y mediste los tres frentes. El desorden quedó contenido, y con él desaparecieron tres errores reales que llevaban meses en producción: el pending_review marcado como fallo, el estado desconocido que el job de conciliación ignoraba en silencio, y el centavo perdido en el int(amount * 100). Las pruebas dejaron de necesitar red. Y cambiar de proveedor pasó de cinco archivos sin verificación posible a uno con pruebas que lo cubren. Viste también el límite: un adaptador se paga con lo que traduce, no con lo que renombra.

Antes de avanzar deberías poder: hacer los cinco pasos sobre una dependencia nueva; explicar por qué un rechazo devuelve un resultado y una caída de red lanza una excepción; enumerar las traducciones que hace un adaptador tuyo; y decir en qué caso no escribirías uno.

La lección 4 cambia de problema. Hasta aquí trabajamos sobre algo que hablaba otro idioma; ahora, sobre algo que habla el nuestro pero pide demasiados pasos. Publicar un evento en Boletia exige siete llamadas en orden; comprar un boleto, diez. Quien lo usa solo quiere una. Eso es un Facade, la diferencia con el Adapter cabe en una frase —Adapter traduce, Facade simplifica— y su riesgo tiene nombre propio: la fachada que crece hasta convertirse en otro God object.

Recursos