<!-- Source: https://docs.infraio.xyz/es/concepts/refunds -->
<!-- Last updated: 2026-10-04 -->

# Reembolsos

Un **Refund** es una entidad de primera clase, no un flag en la
Order. Puedes emitir reembolsos parciales, múltiples reembolsos
contra la misma Order, o reembolsar + re-cobrar en el mismo flujo.

> **Note:**
>
> Los reembolsos también se pueden emitir desde la [app para comerciantes](https://docs.infraio.xyz/es/get-started/merchant-app).

Dos maneras en que puede existir el registro de reembolso:

| Flujo | Quién rellena el formulario | Auth | Aterriza en |
| --- | --- | --- | --- |
| Iniciado por el comerciante | Tu dashboard / tu backend | HMAC (sk_…) | `APPROVED` inmediatamente |
| Iniciado por el cliente | El comprador, en nuestra página alojada | Token de un solo uso (sin credenciales) | `PENDING`: tú apruebas, o se cortocircuita si tu config auto-aprueba |

El flujo iniciado por el cliente usa un **token de solicitud de
reembolso** de corta duración. Emites un token (B2B o dashboard),
entregas la URL al comprador como prefieras, y el comprador completa
los detalles del reembolso en
`checkout.infraio.xyz/refund-request/:token`. El comprador nunca
toca tu API y nunca ve tu clave de comerciante.

## Ciclo de vida del reembolso

```mermaid
stateDiagram-v2
    [*] --> PENDING:  refund created (customer submit or B2B customer-flow)
    PENDING --> APPROVED: passes review (auto for merchant-initiated)
    PENDING --> REJECTED: review denies
    APPROVED --> EXECUTED: on-chain tx confirmed
    APPROVED --> REJECTED: canceled before execution
    REJECTED --> [*]
    EXECUTED --> [*]
```

| Estado | Significa |
| --- | --- |
| `PENDING` | Reembolso registrado, a la espera de aprobación. Los reembolsos iniciados por el cliente siempre empiezan aquí. |
| `APPROVED` | Aprobado para ejecución. Los reembolsos iniciados por el comerciante saltan aquí directamente. |
| `REJECTED` | Reembolso denegado. El estado de la Order no cambia. |
| `EXECUTED` | Transferencia on-chain confirmada. La Order se mueve a `PARTIALLY_REFUNDED` / `REFUNDED`. |

---

## Iniciado por el comerciante

Decides reembolsar (p. ej., el comprador se quejó por chat). Llama
al endpoint iniciado por el comerciante: se salta la revisión y
aterriza en `APPROVED` inmediatamente.

```http
POST /b2b/v1/merchants/{merchant_id}/refunds
{
  "order_id": "ord_01J5K…",
  "amount": "49.00",                       // parcial o total, en la moneda de visualización de la orden
  "reason": "customer complaint #4521",
  "refund_to_address":   "0xBUYER…",       // requerido para rails cripto
  "refund_network":      "polygon",        // slug de red; ver Conceptos → Cadenas
  "refund_token_address":"0xUSDC_CONTRACT" // contrato ERC-20 a devolver; normalmente el token original
}
```

No hay campo `currency` en la solicitud de reembolso: los reembolsos
siempre heredan la moneda de visualización de la orden (USD hoy).
La terna `(refund_to_address, refund_network, refund_token_address)`
es el destino on-chain. Se ignoran para rails fiat (auto-enrutadas por el
proveedor).

La orden conserva su estado existente hasta que ejecutas la
transferencia on-chain (ver
[Ejecutar un reembolso cripto](#executing-a-crypto-refund)).

---

## Iniciado por el cliente — tokens de solicitud de reembolso

El comprador rellena el formulario de reembolso **en nuestra página
alojada**, no en la tuya. Tu único trabajo es emitir un token y
entregar la URL.

### Ciclo de vida del token

```mermaid
stateDiagram-v2
    [*] --> ACTIVE:           mint (B2B or dashboard)
    ACTIVE --> SUBMITTED:     buyer submits the form
    ACTIVE --> EXPIRED_UNUSED: now > expires_at
    ACTIVE --> CANCELED:      merchant cancels (dashboard)
    EXPIRED_UNUSED --> RENEWAL_REQUESTED: buyer clicks "Request new link"
    RENEWAL_REQUESTED --> RENEWED: merchant approves, new ACTIVE token issued
    SUBMITTED --> [*]
    CANCELED --> [*]
    RENEWED --> [*]
```

| Estado | Significa | La URL del cliente renderiza |
| --- | --- | --- |
| `ACTIVE` | El token está activo, `now < expires_at` | El formulario de reembolso (`refund_to_address`, `reason`, `amount`, nota opcional → `metadata.note`) |
| `SUBMITTED` | El comprador completó el formulario; existe un registro de reembolso | Tarjeta de estado reflejando `/r/:linkToken` |
| `EXPIRED_UNUSED` | TTL transcurrido antes de que el comprador enviase | Prompt: "Este enlace ha expirado. Solicita uno nuevo" |
| `RENEWAL_REQUESTED` | El comprador pidió un enlace nuevo | Aviso de espera: "Tu comerciante ha sido notificado" |
| `RENEWED` | El comerciante aprobó la renovación y emitió un reemplazo | "Este enlace ha sido reemplazado: revisa tu email para el nuevo enlace" (el nuevo token **no** se revela aquí, para frustrar ataques de enlaces reenviados) |
| `CANCELED` | El comerciante revocó el token desde el dashboard | Simple "Esta solicitud de reembolso fue cancelada" |

> **Note:**
>
> Los tokens son de un solo uso. Una vez `SUBMITTED`, la URL sigue
> siendo válida para que el comprador compruebe el estado pero no
> puede usarse para enviar de nuevo. Para emitir un segundo reembolso
> contra la misma orden, emite un nuevo token.

### TTL por defecto

| Fuente de emisión | TTL por defecto | Por qué |
| --- | --- | --- |
| `POST /b2b/v1/merchants/{merchant_id}/refund-requests` (HMAC) | **30 minutos** | Programático: se asume que se entrega al comprador inmediatamente. |
| Dashboard del comerciante | **24 horas** | Manual: el comerciante pega la URL en un email / SMS. |

Puedes sobreescribir el valor por defecto con el campo `ttl_seconds`
del body. No se aplica mínimo ni máximo; los valores comunes van de
1 minuto a 7 días.

### Emitir vía la API B2B

Para backends que quieren generar programáticamente un enlace de
reembolso justo tras una conversación de soporte, un flujo de
cancelación de orden, etc.

```http
POST /b2b/v1/merchants/{merchant_id}/refund-requests
Content-Type: application/json
X-Client-ID: pk_live_…
X-Timestamp: 1729536000
X-Signature: 4f2a1b9c8d3e2f1a0b9c8d7e6f5a4b3c…

{
  "ref_type":    "order_id",                          // obligatorio: order_id | order_number | session_id | session_key
  "ref_value":   "ord_01J5K…",                        // obligatorio: coincide con ref_type
  "amount":      "49.00",                             // obligatorio — bloquea el máximo que el comprador puede enviar
  "ttl_seconds": 1800,                                // opcional — por defecto 1800 (30 min)
  "metadata":    { "support_ticket": "4521" },        // opcional — clave/valor estilo Stripe
  "hide_summary": false,                              // banderas opcionales de UI para el formulario alojado
  "hide_header":  false
}
```

> **Warning:**
>
> La firma de la petición B2B es **hex en minúsculas crudo** sin
> **prefijo `sha256=`**: ese prefijo solo aparece en firmas de
> webhook *entrantes* (InfraIO Pay → tu servidor). La cadena de firma
> B2B saliente es `METHOD\nPATH\nTIMESTAMP\nBODY`; consulta
> [Autenticación](https://docs.infraio.xyz/es/api-reference/authentication) para el
> algoritmo canónico.

El importe **está** en el body de emisión y es **obligatorio**.
Bloquea el techo que el comprador puede enviar en el formulario:
puede enviar por menos pero nunca por más. (Para reembolsos
parciales, emite un token con el importe parcial; para reembolsos
totales, emite con el total de la orden.)
La forma anterior `{ "order_id": "..." }` sigue aceptándose y se trata
como `ref_type=order_id`, pero las nuevas integraciones deberían usar
el par explícito `ref_type` + `ref_value`.

Respuesta:

```json
{
  "token":      "rfqt_01J7P3Q9R…",
  "refund_url": "https://checkout.infraio.xyz/refund-request/rfqt_01J7P3Q9R…",
  "expires_at": "2026-05-28T10:32:00Z"
}
```

Dispara `refund_request.created` a tus endpoints de webhook (para
que puedas registrar / auditar qué token está actualmente activo
para una orden).

### Emitir vía dashboard

El modal Emitir Reembolso en el [dashboard del comerciante](https://app.infraio.xyz)
expone un toggle: **Ejecutar ahora** vs **Enviar enlace al cliente**.
Elegir el segundo crea un token de solicitud de reembolso (igual que
la llamada B2B de arriba) y te muestra la URL con un botón de copiar y un
código QR. Pégalo en el canal que tenga sentido: email, chat de
soporte, SMS.

### Vía el SDK de JavaScript — `openRefundRequest`

Si ya tienes `@lartech/infraio-checkout-js` en tu stack y quieres
que el comprador complete el reembolso dentro del flujo de tu propia
página (no vía una URL externa), combina la emisión B2B con
`sdk.openRefundRequest()`:

```ts
// Lado del servidor: emite el token
const { token } = await fetch("/api/mint-refund-token", { method: "POST" }).then(r => r.json());

// Lado del cliente: abre el formulario alojado
const sdk = await loadInfraIo("pk_live_yourkeyhere");
const close = sdk.openRefundRequest({
  token,
  mode: "popup",                       // o "redirect" | "embed"
  onSuccess: ({ linkToken, refundId }) => {
    // linkToken → página de estado /r/:linkToken para el comprador.
    // refundId  → referencia de la API B2B para aprobar / rechazar.
    window.location.href = `/r/${linkToken}`;
  },
  onCancel: () => { /* el comprador cerró el popup */ },
  onError:  (err) => { /* ver referencia del SDK */ },
});
```

Consulta la [referencia del SDK → `sdk.openRefundRequest()`](https://docs.infraio.xyz/es/sdks/javascript#sdkopenrefundrequest-)
para la tabla completa de opciones.

### Renovación del cliente — reemisión iniciada por el comprador

Si el comprador abre la URL tras expirar el token, la página ofrece
un botón **Solicitar nuevo enlace** en lugar del formulario. Hacer
clic en él:

1. Envía la solicitud de renovación (sin credenciales: el propio
   enlace la autoriza)
2. Captura opcionalmente una nota de texto libre (`customer_note`)
   que el comprador puede dejar para ti
3. Mueve el token a `RENEWAL_REQUESTED` y dispara
   `refund_request.renewal_requested` a tu webhook

Tu dashboard muestra una insignia en el widget de solicitudes de
renovación. Apruébalo (un clic) y se emite un nuevo token
`ACTIVE`, dispara `refund_request.renewed`, y te deja copiar la
nueva URL para enviar de nuevo. La URL antigua sigue accesible
pero renderiza "Reemplazado: revisa tu email" para que una copia
reenviada de la URL antigua no pueda usarse para pescar la nueva.

---

## Ejecutar un reembolso cripto

La API registra la intención: no mueve fondos. **Tú** firmas y
difundes la transferencia on-chain desde tu wallet de Tesorería,
luego sellas el tx hash de vuelta en el registro del reembolso:

```http
POST /b2b/v1/refunds/:refund_id/submit-tx
Content-Type: application/json

{
  "tx_hash": "0xabcd…",
  "network": "ethereum",
  "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
}
```

Los tres campos del body son obligatorios: el mismo tx hash puede
existir en cadenas diferentes, y puedes reembolsar en una stablecoin
distinta a la que capturó el pago original.

Cuando InfraIO Pay ve que esa transacción alcanza el conteo de
confirmaciones requerido (ver
[Cadenas y activos](https://docs.infraio.xyz/es/concepts/chains)), el reembolso pasa a
`EXECUTED` y se actualiza el total reembolsado de la Order.

> **Warning:**
>
> Deliberadamente no tenemos custodia de los fondos del comerciante,
> lo que significa que no podemos ejecutar reembolsos en tu nombre.
> Integra el envío on-chain en tu tooling de admin:
> `eth_sendRawTransaction` desde un multisig o una hot wallet, con un
> workflow que termine publicando el tx hash en la API de reembolso.

### Reembolsos en TRON, Solana y TON

El flujo es el mismo: envías el reembolso desde tu propia wallet y luego envías el hash de la transacción. Los detalles siguen a la red:

- La pantalla de reembolso del dashboard muestra el destino, el monto, la red y el token, además de un código QR cuando la red lo admite: un QR de Solana Pay en Solana y un enlace de transferencia TON en TON. En TRON muestra la dirección de destino para copiar (ningún enlace de wallet lleva el monto), así que introduce el monto tú mismo.
- `token_address` es la dirección del token en esa red: el contrato TRC-20, el mint SPL o la dirección del Jetton master.
- Los formatos del hash de transacción difieren: hex sin prefijo en TRON, una firma base58 en Solana y un hash hex o base64 en TON.
- La plataforma verifica esa transacción exacta on-chain y luego pasa el reembolso a `EXECUTED`, usando los conteos de confirmación de [Cadenas y activos](https://docs.infraio.xyz/es/concepts/chains).

---

## Eventos de webhook

El subsistema de reembolsos dispara dos familias de eventos:

### Ciclo de vida del token (`refund_request.*`)

| Evento | Se dispara cuando |
| --- | --- |
| `refund_request.created` | Se emitió un token: `data.source` es `b2b` / `dashboard` / `renewal` |
| `refund_request.renewal_requested` | Un comprador hizo clic en "Solicitar nuevo enlace" tras expirar su token. **Suscríbete a esto: es la señal para que el comerciante actúe.** |
| `refund_request.renewed` | Aprobaste una renovación y un nuevo token reemplazó al antiguo. `data.old_token` / `data.new_token` forman la cadena de auditoría. |
| `refund_request.canceled` | Cambiaste un token a `CANCELED` desde el dashboard. Idempotente: solo la primera transición emite. `data.reason` es la nota opcional del comerciante. |

### Ciclo de vida del reembolso (`payment.refund.*`)

| Evento | Se dispara cuando |
| --- | --- |
| `payment.refund.requested` | Existe una nueva fila Refund: cualquier fuente (envío de formulario, API iniciada por el comerciante, dashboard). |
| `payment.refund.approved` | El reembolso está aprobado: ya sea auto-aprobado (iniciado por el comerciante) o tras llamar a `/approve` sobre uno pendiente. |
| `payment.refund.rejected` | Llamaste a `/reject` sobre un reembolso pendiente. |
| `payment.refund.executed` | Los fondos se han movido (tu tx hash cripto alcanzó las confirmaciones requeridas). |

`payment.failed` **no** se dispara para un reembolso: los reembolsos
tienen su propia serie de eventos bajo el prefijo `payment.refund.*`.

## Qué sigue

- [Referencia del SDK → `sdk.openRefundRequest()`](https://docs.infraio.xyz/es/sdks/javascript#sdkopenrefundrequest-): abre el formulario de reembolso alojado como popup / redirect / embed.
- [Referencia de la API → Reembolsos](https://docs.infraio.xyz/es/api-reference#refunds): catálogo de endpoints (emitir, enviar, renovación, estado).
- [Conceptos → Órdenes](https://docs.infraio.xyz/es/concepts/orders): cómo el estado de Refund vuelve a vincularse con el ciclo de vida de la Order.
- [Webhooks → Resumen](https://docs.infraio.xyz/es/webhooks/overview): catálogo completo de eventos.
