Metadata-Version: 2.5
Name: newdev-tbank
Version: 0.3.0
Summary: Cliente Python para el microservicio Webpay Plus de NewDev
Project-URL: Homepage, https://github.com/newdev-cl/newdev-tbank
Project-URL: Issues, https://github.com/newdev-cl/newdev-tbank/issues
Author-email: NewDev <contacto@newdev.cl>
License: MIT
Keywords: chile,microservicio,payments,transbank,webpay
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Requires-Dist: pydantic>=2.0
Requires-Dist: requests>=2.28
Provides-Extra: dev
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# newdev-tbank — Cliente Python para Webpay Plus

Librería cliente para el microservicio de pagos Webpay Plus de NewDev.

## Instalación

```bash
pip install newdev-tbank
```

## Instalación (desarrollo / sin PyPI)

Si querés instalar el cliente directamente desde el código fuente sin publicarlo a PyPI:

```bash
# Opción 1: editable (recomendado para desarrollo)
# Cambios en el source se reflejan al instante
pip install -e /ruta/al/newdev_tbank_client/

# Opción 2: copia normal (no editable)
pip install /ruta/al/newdev_tbank_client/
```

Después de instalarlo, verificá que importa correctamente:

```bash
python -c "from newdev_tbank import WebpayClient; print('OK')"
```

Esto resuelve las dependencias (`requests`, `pydantic`) desde PyPI, pero instala el paquete `newdev_tbank` desde el directorio local. Ideal para integrar con un proyecto Django existente durante desarrollo o debuggeo.

## Uso rápido

```python
from newdev_tbank import WebpayClient, TransactionStatus

client = WebpayClient(
    api_key="TU_API_KEY",
    base_url="https://api.tubanco.io/webpay",
)

# 1. Crear transacción
tx = client.create_transaction(
    buy_order="FAC-2024-001",
    amount=5000,
    return_url="https://miapp.cl/checkout/success",
    meta={"user_id": 42},
)

# 2. Redirigir al usuario a Transbank (agregar token_ws al redirect)
redirect_url = f"{tx.url_redirect}?token_ws={tx.token}"
return redirect(redirect_url)

# 3. Transbank redirige al usuario de vuelta a return_url con token_ws
# 4. El frontend llama al backend con el token_ws; el backend confirma:
result = client.commit_transaction(token_ws)

if result.status == TransactionStatus.AUTHORIZED:
    mark_invoice_paid(result.authorization_code)
```

## Flujo completo con Django (ejemplo)

El flujo de Webpay Plus REST no incluye webhooks servidor-a-servidor.
Transbank redirige al usuario al `return_url` del frontend con un parámetro
de querystring (`token_ws` o `TBK_TOKEN`). El frontend debe pasar ese
token al backend, que llama al microservicio para confirmar el pago:

```python
# views.py
from django.http import JsonResponse
from newdev_tbank import WebpayClient, TransactionStatus

client = WebpayClient(
    api_key=settings.NEWDEV_TBANK_API_KEY,
    base_url=settings.PAGOS_MICROSERVICE_URL,
)


def checkout_success(request):
    """Vista que atiende el return_url de Transbank."""
    token_ws = request.GET.get("token_ws") or request.GET.get("TBK_TOKEN")
    if not token_ws:
        return JsonResponse({"error": "missing token"}, status=400)

    result = client.commit_transaction(token_ws)

    if result.status == TransactionStatus.AUTHORIZED:
        mark_invoice_paid(result.buy_order, result.authorization_code)
        return JsonResponse({"ok": True, "status": result.status})

    return JsonResponse(
        {"ok": False, "status": result.status},
        status=402,
    )
```

```python
# urls.py
from django.urls import path
from .views import checkout_success

urlpatterns = [
    path("checkout/success/", checkout_success, name="checkout_success"),
]
```

```python
# views.py (creación de transacción)
def start_payment(request):
    tx = client.create_transaction(
        buy_order="FAC-2024-001",
        amount=5000,
        return_url="https://miapp.cl/checkout/success",
    )
    # Redirigir al usuario a Transbank
    redirect_url = f"{tx.url_redirect}?token_ws={tx.token}"
    return redirect(redirect_url)
```

## API

### `WebpayClient`

| Método | Descripción |
|---|---|
| `create_transaction(buy_order, amount, return_url, notify_url=None, ...)` | Crea transacción y retorna `token` + `url_redirect` |
| `commit_transaction(token_ws)` | Confirma el pago con el `token_ws` recibido de Transbank; retorna `TransactionResponse` con el status final |
| `get_transaction(buy_order)` | Consulta los detalles de una transacción (BD local) |
| `get_status(token_ws)` | Consulta el estado actual de una transacción directamente en Transbank (recuperación/polling) |
| `capture(buy_order, amount, authorization_code=None)` | Captura una transacción diferida; el microservicio usa el código almacenado si se omite |
| `refund(buy_order, amount, reason=None)` | Reembolsa una transacción `AUTHORIZED` |
| `get_health()` | Healthcheck del microservicio |

### Constantes

```python
from newdev_tbank import TransactionStatus

TransactionStatus.INITIALIZED           # "INITIALIZED"
TransactionStatus.AUTHORIZED            # "AUTHORIZED"
TransactionStatus.CAPTURED              # "CAPTURED"
TransactionStatus.REVERSED              # "REVERSED"
TransactionStatus.FAILED                # "FAILED"
TransactionStatus.NULLIFIED             # "NULLIFIED"
TransactionStatus.PARTIALLY_NULLIFIED   # "PARTIALLY_NULLIFIED"
TransactionStatus.ERROR                 # "ERROR"
```

## Errores

| Excepción | Cuándo |
|---|---|
| `NewDevTbankError` | Base para todas las excepciones |
| `ApiKeyError` | API-key inválida o expirada (401) |
| `TransactionNotFoundError` | `buy_order` no existe (404) |
| `TransbankError` | Transbank rechazó la transacción |
| `WebpayServiceError` | Error interno del microservicio |

## Integración con el cliente Python

### Flujo básico (compra aprobada)

```python
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import redirect

from newdev_tbank import TransactionStatus, WebpayClient

client = WebpayClient(
    api_key=settings.NEWDEV_TBANK_API_KEY,
    base_url=settings.PAGOS_MICROSERVICE_URL,
)


# 1. Crear transacción
def start_payment(request):
    tx = client.create_transaction(
        buy_order="FAC-2024-001",
        amount=5000,
        return_url="https://miapp.cl/checkout/success",
    )
    redirect_url = f"{tx.url_redirect}?token_ws={tx.token}"
    return redirect(redirect_url)


# 2. Transbank redirige al usuario a return_url con token_ws
# 3. Confirmar el pago
def checkout_success(request):
    token_ws = request.GET.get("token_ws") or request.GET.get("TBK_TOKEN")
    if not token_ws:
        return JsonResponse({"error": "missing token"}, status=400)

    result = client.commit_transaction(token_ws)

    if result.status == TransactionStatus.AUTHORIZED:
        mark_invoice_paid(result.buy_order, result.authorization_code)
        return JsonResponse({"ok": True, "status": result.status})

    # FAILED, REVERSED o ERROR
    return JsonResponse(
        {"ok": False, "status": result.status}, status=402
    )
```

```python
# urls.py
urlpatterns = [
    path("checkout/start/", start_payment, name="start_payment"),
    path("checkout/success/", checkout_success, name="checkout_success"),
]
```

### Flujo de captura diferida

Para commerce codes configurados para *captura diferida*, el monto autorizado se
captura de forma explícita. **El amount debe ser exactamente igual al monto
original autorizado.**

```python
from newdev_tbank import TransactionStatus, WebpayClient

client = WebpayClient(
    api_key=settings.NEWDEV_TBANK_API_KEY,
    base_url=settings.PAGOS_MICROSERVICE_URL,
)


def capture_payment(request):
    token_ws = request.GET.get("token_ws")
    if not token_ws:
        return JsonResponse({"error": "missing token"}, status=400)

    # 1. Confirmar el pago (obtiene authorization_code)
    result = client.commit_transaction(token_ws)
    if result.status != TransactionStatus.AUTHORIZED:
        return JsonResponse({"ok": False, "status": result.status}, status=402)

    # 2. Capturar el monto exacto autorizado
    captured = client.capture(
        buy_order=result.buy_order,
        amount=result.amount,  # debe ser == al monto original autorizado
    )

    return JsonResponse({
        "ok": True,
        "captured_amount": captured.captured_amount,
        "authorization_date": captured.authorization_date,
    })
```

### Flujo de polling / recuperación ante error

Si el servicio estaba caído cuando el usuario volvió de Transbank, se puede
consultar el estado directamente y luego confirmar:

```python
from newdev_tbank import TransactionStatus, WebpayClient

client = WebpayClient(
    api_key=settings.NEWDEV_TBANK_API_KEY,
    base_url=settings.PAGOS_MICROSERVICE_URL,
)


def recover_transaction(token_ws):
    # 1. Consultar estado directamente en Transbank
    status = client.get_status(token_ws)

    if status.status == TransactionStatus.AUTHORIZED:
        if status.captured_amount is not None:
            print(f"Ya capturado: {status.captured_amount}")
        elif status.authorization_code:
            # Capturar si la transacción es diferida
            client.capture(
                buy_order=status.buy_order,
                amount=status.amount,
                authorization_code=status.authorization_code,
            )

    # 2. Normalizar el estado local con commit
    result = client.commit_transaction(token_ws)
    return result
```

### Manejo de errores

```python
from newdev_tbank.exceptions import (
    ApiKeyError,
    TransactionNotFoundError,
    TransbankError,
    WebpayServiceError,
)

try:
    result = client.commit_transaction(token_ws)
except ApiKeyError:
    # API key inválida
    ...
except TransactionNotFoundError:
    # La transacción no existe
    ...
except TransbankError as exc:
    # Transbank rechazó la operación
    print(f"Transbank error: {exc} (code: {exc.response_code})")
except WebpayServiceError as exc:
    # Error interno del microservicio
    print(f"Service error: {exc}")
```

## Testing utilities

El paquete incluye `newdev_tbank.testing` con utilidades para testear sin
tocar la red ni mockgear con `MagicMock` genérico. Las responses siguen
validando tipos gracias a los modelos Pydantic reales.

### `FakeWebpayClient`

Doble in-memory de `WebpayClient` con la misma interfaz (`create_transaction`,
`commit_transaction`, `get_transaction`, `get_status`, `capture`, `refund`,
`get_health`). Ideal para testear código que consume el cliente.

```python
from newdev_tbank import TransactionStatus
from newdev_tbank.exceptions import TransactionNotFoundError
from newdev_tbank.testing import FakeWebpayClient

fake = FakeWebpayClient()

# Simula la creación de una transacción
tx = fake.create_transaction(
    buy_order="FAC-001",
    amount=5000,
    return_url="https://miapp.cl/ok",
)
assert tx.token == "token_FAC-001"

# Simula el commit (confirmación del pago)
fake.set_status("FAC-001", "AUTHORIZED", authorization_code="123456")
result = fake.commit_transaction("token_FAC-001")
assert result.status == TransactionStatus.AUTHORIZED
assert result.authorization_code == "123456"

# Consultar el estado directamente en Transbank (status polling)
status = fake.get_status("token_FAC-001")
assert status.status == TransactionStatus.AUTHORIZED

# Capturar una transacción diferida
captured = fake.capture("FAC-001", amount=3000, authorization_code="123456")
assert captured.captured_amount == 3000

# Las excepciones son idénticas al cliente real
import pytest
with pytest.raises(TransactionNotFoundError):
    fake.get_transaction("NOPE")

fake.reset()  # limpia el estado entre tests
```

### Factories de respuestas

Helpers para construir rápidamente respuestas Pydantic reales:

```python
from newdev_tbank.testing import (
    make_capture_response,
    make_create_transaction_response,
    make_refund_response,
    make_status_response,
    make_transaction_response,
    make_transaction_status_response,
)

make_create_transaction_response("FAC-001", url_redirect="https://x.cl/pay")
make_transaction_status_response("FAC-001", status="AUTHORIZED", amount=5000)
make_status_response("FAC-001", status="AUTHORIZED", authorization_code="123456")
make_capture_response("FAC-001", captured_amount=5000, authorization_code="123456")
make_refund_response("FAC-001", amount=2500)
make_transaction_response("FAC-001", status="AUTHORIZED")
```

## Notas

- El flujo no incluye webhooks: el cliente no expone `verify_webhook` ni
  `compute_webhook_signature` desde la versión que adopta el flujo sin
  webhook de Transbank.
- `notify_url` es opcional; no se despacha ninguna notificación.

## Desarrollo

```bash
# Instalar dependencias de desarrollo
pip install -e ".[dev]"

# Tests
pytest tests/ -v --cov=newdev_tbank

# Lint
ruff check src/
```

## Publicar a PyPI

```bash
python -m build
twine check dist/*
twine upload dist/*
```

## Licencia

MIT
