REFUND
概述
当一笔 PIX 退款的状态发生变化时,会发送 REFUND Webhook。存在两种情形,各自对应控制台中登记的一个事件:
refund_in事件(CashInReversal):您退还了一笔已收到的 PIX(例如通过PUT /api/pix/:e2eid/devolucao/:id)refund_out事件(CashOutReversal):收款人退还了您发送的一笔 PIX
两种情形都以 type: "REFUND" 送达。要以 V2 格式接收,请将这些事件的 URL 登记为 PIX BACEN 格式。
负载结构
{
"data": {
"id": 12345,
"txId": "7978c0c97ea847e78e8849634473c1f1",
"pixKey": "7d9f0335-8dcc-4054-9bf9-0dbd61d36906",
"status": "REFUNDED",
"payment": {
"amount": "100",
"currency": "BRL"
},
"refunds": [
{
"status": "LIQUIDATED",
"payment": {
"amount": 50,
"currency": "BRL"
},
"errorCode": null,
"eventDate": "2024-01-15T10:30:00.000Z",
"endToEndId": "D12345678202401151030abcdefghijk",
"information": "Devolução solicitada pelo recebedor"
}
],
"createdAt": "2024-01-15T09:00:00.000Z",
"errorCode": null,
"endToEndId": "E12345678202401150900abcdefghijk",
"providerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"ticketData": {},
"webhookType": "REFUND",
"debtorAccount": {
"ispb": null,
"name": null,
"bankName": null,
"issuer": null,
"number": null,
"document": null,
"accountType": null
},
"idempotencyKey": "7978c0c97ea847e78e8849634473c1f1",
"creditDebitType": "DEBIT",
"creditorAccount": {
"ispb": "12345678",
"name": "Carlos Oliveira",
"bankName": "Banco Exemplo S.A.",
"issuer": "000",
"number": "123456",
"document": "12345678909",
"accountType": null
},
"localInstrument": "DICT",
"transactionType": "PIX",
"remittanceInformation": "Devolução solicitada pelo recebedor"
},
"type": "REFUND"
}refund_in 与 refund_out
您退还了一笔已收到的 PIX。
creditDebitType = DEBIT (saindo da sua conta)
debtorAccount = Sua conta (campos null)
creditorAccount = Quem recebe de volta示例:您收到 R$ 100,退还了 R$ 50。
收款人退还了您发送的一笔 PIX。
creditDebitType = CREDIT (entrando na sua conta)
debtorAccount = Quem está devolvendo
creditorAccount = Sua conta (campos null)示例:您发送了 R$ 100,收款人退还了 R$ 30。
重要字段
typestring始终为 "REFUND"。
data.idnumber原始交易的 ID(不是退款的 ID)。
data.txIdstring原始交易的外部标识符:收款的 txid,或原始转账的 x-idempotency-key。data.idempotencyKey 中为同一值。
data.statusstring触发该 Webhook 的退款的状态:
REFUNDED:退款已完成ERROR:退款失败PENDING:退款处理中
data.paymentobject原始交易的金额,而不是退款金额。
data.refundsarray只包含一项:触发此 Webhook 的退款。每笔部分退款都会以各自的 Webhook 送达。
data.providerIdstring退款在结算提供商处的 ID。
data.creditDebitTypestring资金方向:
DEBIT:从您的账户流出(refund_in)CREDIT:流入您的账户(refund_out)
data.endToEndIdstring原始交易的 End to End ID。
data.createdAtstring原始交易的创建日期。
处理 Webhook
Node.js 示例
interface RefundWebhook {
type: 'REFUND';
data: {
id: number;
txId: string | null;
status: 'PENDING' | 'REFUNDED' | 'ERROR';
payment: {
amount: string;
currency: 'BRL';
};
refunds: Array<{
status: 'PENDING' | 'LIQUIDATED' | 'ERROR';
payment: {
amount: number;
currency: 'BRL';
};
errorCode: string | null;
endToEndId: string | null;
eventDate: string;
information: string | null;
}>;
endToEndId: string | null;
providerId: string | null;
creditDebitType: 'CREDIT' | 'DEBIT';
};
}
async function handleRefund(webhook: RefundWebhook) {
const { data } = webhook;
const refund = data.refunds[0];
if (data.creditDebitType === 'DEBIT') {
await handleRefundIn(data, refund);
} else {
await handleRefundOut(data, refund);
}
}
async function handleRefundIn(data: RefundWebhook['data'], refund: RefundWebhook['data']['refunds'][number]) {
const original = await findReceiptByE2eId(data.endToEndId);
if (data.status === 'REFUNDED') {
await registerRefundSent({
originalId: original.id,
amount: refund.payment.amount,
refundE2eId: refund.endToEndId,
});
}
}
async function handleRefundOut(data: RefundWebhook['data'], refund: RefundWebhook['data']['refunds'][number]) {
const original = await findTransferByIdempotencyKey(data.txId);
if (data.status === 'REFUNDED') {
await registerRefundReceived({
originalId: original.id,
amount: refund.payment.amount,
refundE2eId: refund.endToEndId,
});
}
}Python 示例
from decimal import Decimal
def handle_refund(webhook: dict):
data = webhook['data']
refund = data['refunds'][0]
if data['status'] != 'REFUNDED':
return
amount = Decimal(str(refund['payment']['amount']))
if data['creditDebitType'] == 'DEBIT':
original = find_receipt_by_e2e(data['endToEndId'])
register_refund_sent(original.id, amount, refund['endToEndId'])
else:
original = find_transfer_by_idempotency_key(data['txId'])
register_refund_received(original.id, amount, refund['endToEndId'])部分退款
一笔交易可以有多次部分退款。每一次都会生成各自的 REFUND Webhook,data.id 和 data.payment.amount 相同(均为原始交易的值),refunds 中的项则各不相同。
要知道还可以退多少,请在您的系统中汇总已完成的退款:
const valorOriginal = Number(data.payment.amount);
const totalDevolvido = await sumCompletedRefunds(data.id);
const saldoDisponivel = valorOriginal - totalDevolvido;注意:refunds 中的 amount 是 number
在 refunds 数组中,payment.amount 字段是 number,而不是 string。
const originalAmount = data.payment.amount;
const refundAmount = data.refunds[0].payment.amount;originalAmount 是字符串(例如 "100"),refundAmount 是数字(例如 50)。
幂等性
由于 data.id 是原始交易的 ID,它会在多次部分退款之间重复。请按退款去重:
async function handleWebhook(webhook: RefundWebhook) {
const refund = webhook.data.refunds[0];
const refundKey = refund.endToEndId ?? webhook.data.providerId;
const key = `refund:${webhook.data.id}:${refundKey}:${webhook.data.status}`;
const isNew = await redis.set(`webhook:${key}`, '1', 'NX', 'EX', 60 * 60 * 24 * 7);
if (!isNew) return;
await handleRefund(webhook);
}错误处理
如果 data.status 为 ERROR,则退款失败。错误码位于 data.errorCode 和 refunds[0].errorCode:
if (data.status === 'ERROR') {
await notifyRefundFailed({
originalE2eId: data.endToEndId,
errorCode: data.errorCode,
});
}