Concurrency and idempotency

Updated

Paddle delivers transaction.paid and transaction.completed (and adjustment.created / .updated) about a second apart, often concurrently, and retries any non-2xx response. The plugin therefore never relies on “check, then act” without a lock.

Locks

Orders::lock( $key, $timeout = 15 ) / Orders::unlock( $key ) use MySQL GET_LOCK( 'wpinterface_paddle_woo_lock_' . md5( $key ), 15 ). Orders::with_order_lock( $order_id, $callback ) takes order:{id}, passes a fresh order to the callback and always releases. If the lock times out the work still goes ahead, except for renewals.

KeyUsed by
order:{id}Orders::complete(), Orders::record_refund(), Billing adjustments, Classic refund alerts, currency/underpaid notes
renewal:{transaction}Subscriptions::record_renewal(): if the lock is not acquired it throws, the webhook returns 500, and Paddle retries

Billing adjustments and Classic refund alerts call Orders::record_refund() while already holding order:{id}, so the same name is locked twice by one connection (re-entrant on MySQL 5.7+ / MariaDB 10.0.2+).

Fresh reads

A request that waited for a lock may hold a stale order in the object cache. Orders::fresh_order( $id ) removes the order from WooCommerce’s OrderCache (HPOS), deletes the orders and post_meta cache entries, runs clean_post_cache(), and reloads with wc_get_order().

Markers

_wpinterface_paddle_adjustment_ids (order meta, a list) records processed references, through Orders::has_adjustment() / add_adjustment():

  • Billing: the adjustment ID, {id}:approved, {id}:pending, {id}:rejected, superseded:{txn}, currency:{txn}, underpaid:{txn}.
  • Classic: alert:{alert_id}, dispute:{alert_id}, duplicate:{paddle order}, currency:{paddle order}, underpaid:{paddle order}, stale-link:{hash}, plan-mismatch:{subscription}.

Billing event IDs use the transient wpinterface_paddle_woo_evt_{md5} instead.

Completing an order

Orders::complete works under order:{id} with a fresh read. It returns false if the order is paid or refunded. Orders in pending, failed, on-hold, cancelled or checkout-draft are completed with payment_complete( $transaction_id ); a cancelled order gets a “reopened, check the stock” note. Any other status gets a note and is left unchanged.

Recording a renewal

lock renewal:{txn}   (not acquired → throw → HTTP 500 → Paddle retries)
existing = Orders::find_by_transaction( txn )
   existing and paid      → return false (already recorded)
   existing and unpaid    → finish it (a retry after a crash)
   none                   → wcs_create_renewal_order( subscription )
                            set payment method, transaction ID, Paddle meta, save, note
payment_complete( txn ) with Subscriptions::$from_paddle = true
set_next_payment( Paddle's next billing time )
unlock

$from_paddle stops the resulting status change (for example on-hold → active) from being sent back to Paddle.

Store refund ledger

Refunds that WooCommerce already knows about are kept in order meta _wpinterface_paddle_refund_requests so Paddle’s later report isn’t recorded twice. Entries hold marker, amount (null for a full refund), full and status.

  • Refund via Paddle: Orders::add_refund_request() writes a [WC refund {refund_id}] entry with status sending before Paddle is called; the marker is appended to the reason sent to Paddle. Whether the call succeeds or fails, the entry is then removed.
  • Refund manually: Refunds::remember_manual_refund() (on woocommerce_order_refunded) writes a [WC manual refund {id}] entry with status manual.
  • Orders::take_refund_request( $order, $reason, $wc_amount, $full ) decides whether a refund Paddle reports is already in WooCommerce: first a non-manual entry whose marker is in Paddle’s reason; then, if the reason contains [WC refund N], whether that refund still exists; then any entry with the same amount (within 0.02) or both full. A matching entry is removed.

Was this page helpful?