Refunds travel in both directions. Store-side requests go through the ledger described in Concurrency and idempotency.
WooCommerce → Paddle
WooCommerce calls Gateway::process_refund( $order_id, $amount, $reason ) for Refund via Paddle after it has created the WC_Order_Refund. The plugin finds that refund, sets $full = count( refunds ) <= 1 && amount >= order total − 0.01, and calls driver->refund(). On success it adds a note with Paddle’s reference and returns true; on failure it returns a WP_Error.
Why a failed refund deletes the WooCommerce refund
When process_refund() returns an error, WooCommerce’s wc_create_refund() deletes the refund it just created and shows the error. The ledger entry is removed too. If Paddle made the refund after all (a timeout), its webhook finds no matching entry and the refund is recorded then.
- Billing:
GET /adjustments?transaction_id=…&action=refund, keeping pending and approved ones.type: fullwhen$fulland nothing was refunded before. Otherwisetype: partialwithitems: the Paddle lines of the refunded order lines first (through_wpinterface_paddle_line_mapand each refund item’s_refunded_item_id), then the rest over lines that still have money left. WooCommerce amounts are scaled by Paddle total ÷ WooCommerce total, because Paddle may have added tax. Throws if more is asked than is left. The adjustment ID is added to the markers immediately. - Classic:
payment/refundwith the Paddle order ID, no amount for full refunds, or the amount scaled by_wpinterface_paddle_total÷ order total.
Paddle → WooCommerce
Orders::record_refund( $order, $amount, $full, $note, $targets = array() ) runs under the order lock with a fresh read:
- Adds the note. Returns false if nothing is left to refund.
- If
$fullor the amount covers what is left, refunds what is left. - Calls
wc_create_refund()withrefund_payment => false,restock_items => falseand reason “Refunded in Paddle”. $targetsbecome line items only when the order has no WooCommerce tax and the lines add up to the refund; otherwise the refund is recorded as an amount.- If WooCommerce refuses, a note asks the store owner to use Refund manually.
Webhook handling
Billing adjustment.* (found by transaction_id, same environment, under the order lock):
| Action / status | Result |
|---|---|
| refund, rejected | Once: a note; for store refunds a loud note that the customer was not refunded although WooCommerce shows the refund |
| refund, pending_approval | Note, unless it is a store refund |
| refund, approved, known ID | Approved note once |
| refund, approved, unknown ID | Markers added; if take_refund_request() matches, a note only; otherwise record_refund() |
| chargeback, approved | Order → on-hold |
| chargeback_warning, chargeback_reverse | Note |
Classic payment_refunded / subscription_payment_refunded (found by order ID, same environment, under the order lock): deduplicated by alert:{alert_id}; the amount is scaled to WooCommerce terms; take_refund_request() with the refund reason; VAT refunds only add a note; otherwise record_refund() (always an amount, never line targets).
Chargebacks
Billing chargeback (approved) and Classic payment_dispute_created put the order on hold with a note; each is processed once. Warnings and reversals only add notes. Nothing is refunded in WooCommerce automatically.