# Cancelación de reembolsos

Cómo se previsualiza y se cancela un reembolso de factura desde el panel o por MCP (no está en la API externa) y qué contiene el aviso INVOICE_REFUND_CANCELLED.

Cancelar un reembolso deshace una devolución ya registrada en UTILIA OS:
restaura el saldo de la factura, corrige gamificación y CRM (y comisiones y
contabilidad, cuando estén disponibles), y puede cancelar la factura
rectificativa en borrador vinculada. Como mueve dinero y asientos, se hace en
dos pasos:

**Cancelar un reembolso**

1. **Vista previa** (GET …/cancel-preview): Muestra todo lo que cambiaría, sin aplicar nada. Si no: cancellable: false y blockReason explica el motivo.
2. **Cancelación** (POST …/cancel): Con confirm: true, cuando la persona confirma. Si no: 409 si hay una disputa abierta en Stripe.
3. **Aviso INVOICE_REFUND_CANCELLED** (Tu servidor): Lo reciben una vez las aplicaciones suscritas.

> **Atención: No forma parte de la API externa**
>
> Estos endpoints están bajo `/api/finance/invoices/...`, no bajo
> `/api/external/v1/`, y exigen la **sesión de un usuario del panel**
> (`Authorization: Bearer <token>`) con el permiso `INVOICES_REFUND_CANCEL`.
> Una aplicación externa con `X-Api-Key` recibe `401`, y los SDK no los
> alcanzan.
>
> Lo que **sí** puede hacer una aplicación externa es **emitir** un
> reembolso, con [`POST /external/v1/invoices/:id/refund`](https://os.utilia.ai/docs/developers/api/invoice-actions),
> o **registrar** la devolución que hizo en su propia pasarela al emitir
> una [rectificativa](https://os.utilia.ai/docs/developers/api/invoice-rectifications).
> Deshacerlo después lo decide tu equipo, desde Finanzas o con la
> herramienta MCP.

## Reglas de la operación

- La cancelación es siempre **total**. Para devolver solo una parte, registra otro reembolso por la diferencia.
- El cuerpo exige `confirm: true` como valor booleano. Cualquier otro valor (también `"true"` como texto) devuelve `400`.
- Si el reembolso era de Stripe y estaba pendiente (`PENDING`), se cancela también en Stripe. En el resto de casos (manuales, de una pasarela de pago externa o de Stripe ya completados) se cancela solo en UTILIA OS.
- Un reembolso de **pasarela de pago externa** (`EXTERNAL_GATEWAY`) es el registro de una devolución que la aplicación hizo en su propia pasarela. Cancelarlo anula ese registro en UTILIA OS; el dinero que se movió en esa pasarela no lo toca.
- Si hay una **disputa abierta en Stripe** sobre el cobro, la operación se bloquea con `409`: la disputa la resuelve Stripe.
- Si el reembolso generó una rectificativa **en borrador** o **emitida**, se cancela también por defecto (`alsoCancelRectifyingInvoice: true`). Si ya avanzó a otro estado (por ejemplo, enviada o cobrada), no se toca y la vista previa lo avisa.
- Si el reembolso ya estaba cancelado, la respuesta es `200` con `alreadyCancelled: true` y no se repite ningún efecto.
- Límites: 20 peticiones por minuto y usuario en la vista previa, y 5 en la cancelación.

## Previsualizar la cancelación

**`GET /api/finance/invoices/:invoiceId/refunds/:refundId/cancel-preview`**

Devuelve todo lo que cambiaría al cancelar un reembolso, sin aplicar nada. Se puede repetir sin efectos.

Parámetros de ruta: `invoiceId`, `refundId`.

### Parámetros de ruta

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `invoiceId` | `string` | Sí | Identificador de la factura en UTILIA OS. |
| `refundId` | `string` | Sí | Identificador del reembolso que quieres evaluar. |

### Respuesta

Devuelve la factura, el reembolso, los avisos y los efectos que tendría la cancelación. Si `cancellable` es `false`, `blockReason` explica el motivo en castellano.

```json
{
  "refund": {
    "id": "clx987zyx",
    "source": "STRIPE",
    "status": "PENDING",
    "amount": 25.5,
    "currency": "EUR",
    "createdAt": "2026-04-19T10:14:22.000Z",
    "initiatedBy": { "fullName": "Marta López" }
  },
  "invoice": {
    "id": "clx111aaa",
    "invoiceNumber": "202600042",
    "status": "PARTIALLY_PAID",
    "paidAmount": 224.5,
    "totalAmount": 250.0
  },
  "cancellable": true,
  "blockReason": null,
  "warnings": [
    "El reembolso se emitió hace 32 días; revisa que el cliente no haya solicitado nada en el chat antes de cancelar."
  ],
  "effects": {
    "invoice": { "restorePaidAmount": true, "newPaidAmount": 250.0, "newStatus": "PAID", "reason": null },
    "stripe": { "action": "cancel-on-stripe", "reason": "Se invocará stripe.refunds.cancel sobre re_3OabcDEF porque está en estado PENDING." },
    "commission": { "affected": [{ "commissionId": "clx-c-1", "agentName": "Marta López", "kind": "restore", "amount": 12.5, "alreadySettled": false }] },
    "accounting": {
      "newJournalEntry": {
        "type": "REFUND_CANCELLATION",
        "period": "2026-05",
        "totalDebit": 25.5,
        "totalCredit": 25.5,
        "accounts": [{ "code": "430", "debit": 25.5, "credit": 0 }, { "code": "572", "debit": 0, "credit": 25.5 }]
      }
    },
    "gamification": { "pointsRestored": 50, "affectedUsers": [{ "userId": "u-1", "fullName": "Marta López", "points": 50 }] },
    "crm": { "interactionWillBeCreated": true, "clientName": "Empresa Receptora SL" },
    "customerEmail": { "willBeSent": true, "reason": "Se notificará al cliente porque recibió el justificante del reembolso.", "recipients": ["cliente@empresa.com"] },
    "webhook": { "willBeDispatched": true, "externalApps": ["mi-app-contable"] },
    "rectifyingInvoice": { "exists": true, "id": "clx-r-1", "number": "R-202600005", "status": "DRAFT", "action": "cancel", "verifactuSigned": false, "warning": null }
  }
}
```

### Códigos de respuesta

| Nombre | Tipo | Descripción |
| - | - | - |
| `200` | `OK` | Vista previa generada (también cuando `cancellable` es `false`). |
| `403` | `Forbidden` | El usuario no tiene `INVOICES_REFUND_CANCEL` o la factura queda fuera de su alcance. |
| `404` | `Not Found` | Factura o reembolso no encontrados. |

## Ejecutar la cancelación

**`POST /api/finance/invoices/:invoiceId/refunds/:refundId/cancel`**

Cancela un reembolso ya emitido. Si ya estaba cancelado, devuelve alreadyCancelled: true sin repetir efectos.

Parámetros de ruta: `invoiceId`, `refundId`.

### Cuerpo de la petición

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `cancellationReason` | `string` | Sí | Motivo concreto (de 10 a 500 caracteres, sin contar espacios). Queda en el registro de auditoría, en el aviso, en la interacción del CRM y, si no envías cancellationCustomerMessage, en el correo al cliente. |
| `cancellationNotes` | `string` | No | Notas internas para el equipo de finanzas (máximo 2.000 caracteres). Nunca se envían al cliente. |
| `cancellationCustomerMessage` | `string` | No | Mensaje para el correo al cliente (máximo 500 caracteres). Si lo omites, se usa cancellationReason. |
| `alsoCancelRectifyingInvoice` | `boolean` | No | Por defecto, true. Si la rectificativa vinculada ya pasó de emitida (por ejemplo, está enviada o cobrada), se ignora y la respuesta lo avisa. |
| `confirm` | `boolean` | Sí | Tiene que ser exactamente `true`. Evita cancelaciones por accidente. |

### Respuesta

```json
{
  "refundId": "clx987zyx",
  "status": "CANCELLED",
  "alreadyCancelled": false,
  "invoiceRestored": true,
  "newInvoiceStatus": "PAID",
  "newPaidAmount": 250.0,
  "stripeCancelled": true,
  "rectifyingInvoiceCancelled": true,
  "cancelledAt": "2026-05-08T14:32:11.000Z"
}
```

### Códigos de respuesta

| Nombre | Tipo | Descripción |
| - | - | - |
| `200` | `OK` | Cancelado correctamente o respuesta idempotente cuando ya estaba cancelado. |
| `400` | `Bad Request` | Cuerpo no válido: falta `confirm: true` o `cancellationReason` está vacío o es demasiado corto. |
| `403` | `Forbidden` | Sin permiso `INVOICES_REFUND_CANCEL` o fuera del alcance del usuario. |
| `404` | `Not Found` | Factura o reembolso no encontrados. |
| `409` | `Conflict` | No se puede cancelar: hay una disputa abierta en Stripe, otra cancelación simultánea, la factura no cuadra o la rectificativa no se puede tocar. |
| `502` | `Bad Gateway` | Stripe rechazó la cancelación. El reembolso queda como estaba; vuelve a intentarlo en unos segundos. |

## Desde el producto

En la ficha de la factura, con la acción de cancelar el reembolso. El
diálogo muestra primero el alcance de la cancelación y solo aplica los
cambios cuando la persona confirma.

Los SDK no ofrecen esta operación: `invoices.refunds.cancelPreview` e
`invoices.refunds.cancel` atacaban esta ruta de la API del panel, devolvían siempre
`401` y se retiraron en la versión 3.0.0. Lo que sí ofrecen es
`invoices.refunds.create`, que emite el reembolso por la API externa.

## Herramienta MCP

Un asistente de IA con el permiso `INVOICES_REFUND_CANCEL` puede
hacerlo con la herramienta `crm_invoices_workflow` del servidor MCP de
finanzas: `cancel_refund_preview` para la vista previa y `cancelRefund`
para cancelar.

```json
{
  "tool": "crm_invoices_workflow",
  "operation": "cancelRefund",
  "invoiceId": "inv-uuid",
  "refundId": "refund-uuid",
  "cancellationReason": "Reembolso emitido por error en la conciliación bancaria del 1 de mayo de 2026.",
  "cancellationNotes": "Confirmado por Marta tras revisar el ticket SUP-1234.",
  "cancellationCustomerMessage": "Hemos detectado que el reembolso anterior se emitió por error. Tu factura vuelve a estar pagada y no necesitas hacer nada.",
  "alsoCancelRectifyingInvoice": true
}
```

En MCP no se envía `confirm`: llamar a la herramienta ya cuenta como
confirmación de la persona que la usa.

## Aviso INVOICE_REFUND_CANCELLED

Al completar la cancelación, las aplicaciones suscritas reciben el
aviso `INVOICE_REFUND_CANCELLED`, una sola vez por cancelación (no se
repite en las respuestas con `alreadyCancelled: true`). Su contenido:

| Nombre | Tipo | Descripción |
| - | - | - |
| `invoiceId` | `string` | Identificador de la factura en UTILIA OS. |
| `invoiceNumber` | `string` | Número fiscal de la factura. |
| `refundId` | `string` | Identificador del reembolso cancelado en UTILIA OS. |
| `stripeRefundId` | `string \| null` | Identificador del reembolso en Stripe (`re_xxx`). Nulo si era manual. |
| `source` | `STRIPE \| MANUAL_BANK \| MANUAL_CASH \| MANUAL_OTHER \| EXTERNAL_GATEWAY` | Origen del reembolso original. EXTERNAL_GATEWAY es una devolución hecha en la pasarela propia de la aplicación y registrada en UTILIA OS. |
| `originalAmount` | `number` | Importe del reembolso cancelado, en euros (dos decimales). |
| `originalAmountCents` | `integer` | Mismo importe en céntimos. |
| `currency` | `string` | Divisa (código ISO 4217 en mayúsculas). |
| `originalReason` | `string \| null` | Motivo del reembolso original, con los valores de Stripe (`requested_by_customer`…). |
| `wasFullRefund` | `boolean` | true si el reembolso original devolvió todo lo cobrado. |
| `cancelledById` | `string` | Identificador del usuario que ejecuta la cancelación. |
| `cancelledAt` | `string` | Momento de la cancelación (ISO 8601, UTC). |
| `cancellationReason` | `string` | Motivo de la cancelación. |
| `cancellationNotes` | `string \| null` | Notas internas (no se envían al cliente). |
| `cancellationCustomerMessage` | `string \| null` | Mensaje específico al cliente. |
| `invoiceRestored` | `boolean` | true si la cancelación restauró saldo a la factura. |
| `previousInvoiceStatus` | `InvoiceStatus` | Estado de la factura ANTES de aplicar la cancelación. |
| `newInvoiceStatus` | `InvoiceStatus` | Estado de la factura DESPUÉS de aplicar la cancelación. |
| `stripeCancelled` | `boolean` | true si se invocó `stripe.refunds.cancel`. |
| `rectifyingInvoiceId` | `string \| null` | Identificador de la rectificativa vinculada (nulo si no había). |
| `rectifyingInvoiceWasCancelled` | `boolean` | true si también se canceló la rectificativa en borrador. |
| `paymentInitiator` | `PaymentInitiator \| null` | Copia de los datos de quien pagó originalmente. |
| `paymentInitiatorType` | `string \| null` | Tipo del actor pagador (USER, PORTAL_USER, EXTERNAL_USER, SYSTEM). |
| `paymentInitiatorId` | `string \| null` | Identificador del actor pagador. |

Además del aviso, la cancelación restaura los puntos de gamificación,
añade una interacción en la ficha del cliente en el CRM, avisa al
cliente por correo cuando procede y queda en el registro de auditoría.
Cuando Comisiones y Contabilidad estén disponibles, también revierte las
comisiones (o genera un cargo si ya estaban liquidadas) y crea un asiento
contable de signo contrario sin borrar el original.
