Refunds
Refunds pattern for Node.js.
Refunding orders end-to-end with @easylabs/node — covering full and partial refunds, automatic accept-on-dispute, and tying the reversal back to your local ledger.
Goal
A reusable refund service that:
- Resolves the originating transfer from an order, invoice, or charge.
- Applies a partial or full reversal.
- Persists the reversal ID against your domain object for accounting.
- Reacts to
dispute.createdwebhooks by pre-emptively refunding low-value disputes.
Implementation
1. Refund service
import { createClient } from "@easylabs/node";
// EasyApiError ships from @easylabs/common; @easylabs/node re-exports the
// *type* but not the runtime value, so `instanceof` needs the common import.
import { EasyApiError } from "@easylabs/common";
const easy = await createClient({ apiKey: process.env.EASY_API_KEY! });
type RefundReason = "customer_request" | "duplicate" | "fraud" | "shipping_delay" | "dispute_received";
export async function refundOrder(
orderId: string,
amountCents: number | "full",
reason: RefundReason,
approvedBy: string,
) {
const { data: order } = await easy.getOrder(orderId);
if (!order.transfer) throw new Error("Order has no captured transfer.");
if (order.transfer.state !== "SUCCEEDED") {
throw new Error(`Cannot refund transfer in state ${order.transfer.state}.`);
}
const refund_amount = amountCents === "full" ? order.transfer.amount : amountCents;
try {
const reversal = await easy.createRefund(order.transfer.id, {
refund_amount,
tags: {
reason,
approved_by: approvedBy,
order_number: order.order_number,
},
});
// Persist reversal.data.id against the order in your DB
return reversal.data;
} catch (err) {
if (err instanceof EasyApiError && err.status === 422) {
// Probably trying to refund more than was captured
throw new Error(`Refund rejected: ${err.message}`);
}
throw err;
}
}2. Auto-refund small disputes
import { EasyWebhooks } from "@easylabs/node";
export async function handleWebhook(rawBody: string, sigHeader: string) {
const event = EasyWebhooks.constructEvent(
rawBody,
sigHeader,
process.env.EASY_WEBHOOK_SECRET!,
);
if (event.type === "dispute.created") {
const dispute = event.data as { id: string; amount: number; transfer?: { id: string } };
if (dispute.amount < 1000 && dispute.transfer) {
// Cheaper to refund than to fight
await easy.createRefund(dispute.transfer.id, {
refund_amount: dispute.amount,
tags: { reason: "dispute_received", dispute_id: dispute.id },
});
await easy.acceptDispute(dispute.id);
}
}
}3. Reconcile a reversal back to your ledger
const reversal = await refundOrder(orderId, "full", "customer_request", "agent_42");
console.log({
ledger_entry: "refund",
amount_cents: reversal.amount,
parent_transfer: reversal.parent_transfer,
reversal_id: reversal.id,
state: reversal.state,
});Tradeoffs
- No dedicated list endpoint. To find every reversal for a transfer, list transfers and filter by
type === "REVERSAL"andparent_transfer === originalId, or follow_links.reversals.hrefon the parent. - Partial-refund accumulation. Multiple partial refunds add up against the original capture. The API returns 422 once you exceed it — handle that case in your service.
- Pending state. A new reversal starts as
state: "PENDING"and only flips toSUCCEEDEDwhen the funds clear back. Don't notify the customer "refunded" until you've seen thepayment.updated(or refund-specific) webhook flip the state. - Disputes vs refunds. A refund issued after a dispute is opened may not stop the dispute fee —
acceptDisputeis the cleanest way to close the case in the cardholder's favor. - Subscription invoice refunds. Refunding a subscription's invoice transfer doesn't cancel the subscription. If the customer wants to stop billing too, also call
cancelSubscription(subId, { at_period_end: false }).