REFUND
Descripción general
El webhook REFUND se envia cuando se procesa una devolución PIX. Hay dos escenarios:
- CashInReversal: Usted devolvio un PIX recibido (via
/pix/:e2eid/devolucao/:id) - CashOutReversal: Alguien devolvio un PIX que usted envío
Cuando se envia
- Devolución de un PIX recibido confirmada (usted esta devolviendo)
- Devolución de un PIX enviado recibida (alguien le esta devolviendo a usted)
Estructura del Payload
{
"type": "REFUND",
"data": {
"id": 123,
"txId": "7978c0c97ea847e78e8849634473c1f1",
"pixKey": "7d9f0335-8dcc-4054-9bf9-0dbd61d36906",
"status": "REFUNDED",
"payment": {
"amount": "100.00",
"currency": "BRL"
},
"refunds": [
{
"status": "LIQUIDATED",
"payment": {
"amount": 50.00,
"currency": "BRL"
},
"errorCode": null,
"eventDate": "2024-01-15T10:30:00.000Z",
"endToEndId": "D12345678901234567890123456789012",
"information": "Devolucao solicitada pelo recebedor"
}
],
"createdAt": "2024-01-15T09:00:00.000Z",
"errorCode": null,
"endToEndId": "E12345678901234567890123456789012",
"ticketData": {},
"webhookType": "REFUND",
"debtorAccount": {
"ispb": null,
"name": null,
"issuer": null,
"number": null,
"document": null,
"accountType": null
},
"idempotencyKey": "7978c0c97ea847e78e8849634473c1f1",
"creditDebitType": "DEBIT",
"creditorAccount": {
"ispb": "18236120",
"name": "NU PAGAMENTOS S.A.",
"issuer": "260",
"number": "12345-6",
"document": "123.xxx.xxx-xx",
"accountType": null
},
"localInstrument": "DICT",
"transactionType": "PIX",
"remittanceInformation": "Devolucao parcial"
}
}Diferencia entre CashInReversal y CashOutReversal
Usted devolvio un PIX recibido.
creditDebitType = DEBIT (saliendo de su cuenta)
debtorAccount = Su cuenta
creditorAccount = Quien recibira de vueltaEjemplo: Usted recibio R$ 100, luego devolvio R$ 50.
Alguien devolvio un PIX que usted envío.
creditDebitType = CREDIT (entrando a su cuenta)
debtorAccount = Quien esta devolviendo
creditorAccount = Su cuentaEjemplo: Usted envío R$ 100, el destinatario devolvio R$ 30.
Campos Importantes
typestringSiempre "REFUND" para devoluciones.
data.idnumberID de la transacción original (no de la devolución).
data.statusstringEstado de la transacción original después de la devolución:
REFUNDED: Devolución procesadaERROR: Devolución fallida
data.paymentobjectMonto de la transacción original, no el de la devolución.
data.refundsarrayLista de devoluciones realizadas. Contiene los detalles de cada devolución.
data.creditDebitTypestringDirección del dinero:
DEBIT: Saliendo de su cuenta (CashInReversal)CREDIT: Entrando a su cuenta (CashOutReversal)
data.endToEndIdstringE2E ID de la transacción original.
Procesamiento del Webhook
Ejemplo en Node.js
interface RefundWebhook {
type: 'REFUND';
data: {
id: number;
txId: string | null;
status: 'REFUNDED' | 'ERROR';
payment: {
amount: string;
currency: string;
};
refunds: Array<{
status: 'LIQUIDATED' | 'ERROR';
payment: {
amount: number; // number, no string!
currency: string;
};
endToEndId: string;
eventDate: string;
information: string | null;
}>;
endToEndId: string;
creditDebitType: 'CREDIT' | 'DEBIT';
};
}
async function handleRefund(webhook: RefundWebhook) {
const { data } = webhook;
// Identificar tipo de devolución
const isCashInReversal = data.creditDebitType === 'DEBIT';
if (isCashInReversal) {
// Usted devolvio un PIX recibido
await handleCashInReversal(data);
} else {
// Alguien devolvio un PIX que usted envio
await handleCashOutReversal(data);
}
}
async function handleCashInReversal(data: RefundWebhook['data']) {
// Buscar transacción original
const original = await findTransactionByE2eId(data.endToEndId);
// Procesar cada devolución
for (const refund of data.refunds) {
if (refund.status === 'LIQUIDATED') {
// Devolución confirmada - debitar del saldo
await processRefundOut({
originalId: original.id,
refundAmount: refund.payment.amount, // ya es un numero
refundE2eId: refund.endToEndId,
});
console.log(`Devuelto R$ ${refund.payment.amount} del PIX ${original.id}`);
}
}
}
async function handleCashOutReversal(data: RefundWebhook['data']) {
// Buscar transferencia original
const original = await findTransferByE2eId(data.endToEndId);
// Procesar cada devolución recibida
for (const refund of data.refunds) {
if (refund.status === 'LIQUIDATED') {
// Devolución recibida - acreditar al saldo
await processRefundIn({
originalId: original.id,
refundAmount: refund.payment.amount,
refundE2eId: refund.endToEndId,
});
console.log(`Recibido R$ ${refund.payment.amount} de devolución`);
}
}
}Ejemplo en Python
from decimal import Decimal
def handle_refund(webhook: dict):
data = webhook['data']
# Identificar tipo
is_cash_in_reversal = data['creditDebitType'] == 'DEBIT'
if is_cash_in_reversal:
handle_cash_in_reversal(data)
else:
handle_cash_out_reversal(data)
def handle_cash_in_reversal(data: dict):
"""Usted devolvio un PIX recibido"""
original = find_transaction_by_e2e(data['endToEndId'])
for refund in data['refunds']:
if refund['status'] == 'LIQUIDATED':
# Ya es un numero, convertir a Decimal
amount = Decimal(str(refund['payment']['amount']))
process_refund_out(
original_id=original.id,
refund_amount=amount,
refund_e2e=refund['endToEndId']
)
def handle_cash_out_reversal(data: dict):
"""Alguien devolvio un PIX que usted envio"""
original = find_transfer_by_e2e(data['endToEndId'])
for refund in data['refunds']:
if refund['status'] == 'LIQUIDATED':
amount = Decimal(str(refund['payment']['amount']))
process_refund_in(
original_id=original.id,
refund_amount=amount,
refund_e2e=refund['endToEndId']
)Devoluciones Parciales
Una transacción puede tener multiples devoluciones parciales. El array refunds contiene todas:
{
"type": "REFUND",
"data": {
"payment": { "amount": "100.00" },
"refunds": [
{
"payment": { "amount": 30.00 },
"eventDate": "2024-01-15T10:00:00Z"
},
{
"payment": { "amount": 50.00 },
"eventDate": "2024-01-15T11:00:00Z"
}
]
}
}Calculo del saldo de devolución:
const originalAmount = parseFloat(data.payment.amount); // 100.00
const totalRefunded = data.refunds
.filter(r => r.status === 'LIQUIDATED')
.reduce((sum, r) => sum + r.payment.amount, 0); // 80.00
const availableBalance = originalAmount - totalRefunded; // 20.00Nota: amount es number en refunds
Dentro del array refunds, el campo payment.amount es un number, no un string!
// data.payment.amount -> string "100.00"
// data.refunds[0].payment.amount -> number 50.00
// CORRECTO
const refundAmount = data.refunds[0].payment.amount; // 50.00 (number)
// INCORRECTO - no se necesita parseFloat
const refundAmount = parseFloat(data.refunds[0].payment.amount);Idempotencia
Use una combinacion de data.id y refunds[].endToEndId para idempotencia:
async function handleWebhook(webhook: RefundWebhook) {
for (const refund of webhook.data.refunds) {
const key = `refund:${webhook.data.id}:${refund.endToEndId}`;
const isProcessed = await redis.sismember('processed', key);
if (isProcessed) {
continue; // Ya procesado
}
await redis.sadd('processed', key);
await processRefund(webhook.data, refund);
}
}Manejo de Errores
Si refund.status === 'ERROR', la devolución fallo:
for (const refund of data.refunds) {
if (refund.status === 'ERROR') {
console.error(`Devolución fallida: ${refund.errorCode}`);
// Notificar sobre la falla
await notifyRefundFailed({
originalE2eId: data.endToEndId,
refundE2eId: refund.endToEndId,
errorCode: refund.errorCode,
});
}
}