Documentation / Elite Balance

about the plugin →

Developer API

The public functions, events, and rules for moving money programmatically without ever breaking the ledger's guarantees.

Ground rules

  • Never write to the plugin’s tables directly. Every guarantee (running balances, idempotency, locking) lives in the API layer. Reads are fine; writes go through the functions and services below.
  • Boot your integration on the rb_elite_balance_loaded action: every service is registered and requirements are met when it fires.
  • Amounts are decimal strings with up to four decimal places ('25.50', '10.0000'). Never do float math on money; compare and manipulate amounts as strings or minor units.

Functions

FunctionReturnsWhat it does
rb_elbal_balance( $user_id )stringThe user’s ledger balance, canonical ('25.5000').
rb_elbal_available_balance( $user_id )stringBalance minus pending holds: what checkout can spend right now.
rb_elbal_credit( $user_id, $amount, $args )Transaction or WP_ErrorAdds funds. Creates the wallet if none exists.
rb_elbal_debit( $user_id, $amount, $args )Transaction or WP_ErrorRemoves funds. Fails cleanly on insufficient balance or a locked wallet.

$args accepts type (a registered transaction type; default a plain credit/debit), idempotency_key, note, meta (array), actor_id, reference_type and reference_id.

add_action( 'rb_elite_balance_loaded', function () {
	$result = rb_elbal_credit( 42, '10.00', array(
		'type'            => 'admin_adjust',
		'idempotency_key' => 'loyalty:2026-07:user:42',
		'note'            => 'July loyalty reward.',
		'meta'            => array( 'notify' => 'no' ),
	) );

	if ( is_wp_error( $result ) ) {
		// insufficient_funds, wallet_locked, invalid_amount, ...
		return;
	}

	$result->was_replayed(); // true when this key already ran
	$result->balance_after(); // canonical string
} );

Idempotency keys: read this twice

Pass a deterministic idempotency_key for anything that could run more than once: cron jobs, webhooks, queue workers, retried HTTP calls. The key is what makes a retry replay instead of double-crediting. Build it from your own stable identifiers ('loyalty:2026-07:user:42'), never from timestamps or random values.

If you omit the key, one is generated per call, which means a retried call is a NEW operation and will move money again. Omitting the key is only sensible for genuinely one-off manual operations.

Behavior flags in meta

  • meta.notify = 'no' silences the credited/debited email for that one transaction (the same flag the admin checkboxes set).
  • Everything you put in meta is stored on the ledger row as structured JSON and shown in the admin transaction detail.

Custom transaction types

Register your own types so your integration’s movements are first-class citizens in the ledger, filterable in the admin, and labeled properly:

add_filter( 'rb_elite_balance_transaction_types', function ( $types ) {
	$types['partner_credit'] = array(
		'label'     => __( 'Partner credit', 'my-extension' ),
		'direction' => 'credit', // credit | debit | any
	);
	return $types;
} );

Notes on custom types are hidden from customers by default; opt in via rb_elite_balance_customer_note_types only when your notes are composed by code, never typed by staff.

Events

All events fire after the database commit and never for replays, so acting on them is always safe and always once.

ActionFires when
rb_elite_balance_transaction_createdAny ledger transaction commits. The workhorse.
rb_elite_balance_hold_placed / _captured / _releasedThe checkout reservation lifecycle.
rb_elite_balance_refund_creditedA refund was returned to a wallet.
rb_elite_balance_topup_creditedA paid top-up credited a wallet.
rb_elite_balance_wallet_created / _status_changedWallet lifecycle.
rb_elite_balance_wallet_collection_failedA paid order’s wallet portion could not be collected; alert staff.
rb_elite_balance_withdrawal_requested / _transferred / _rejected / _cancelledThe withdrawal request lifecycle (see below).
rb_elite_balance_earn_awarded / _reversedAn earning rule credited a wallet, or an award was reversed after a refund or cancellation (see below).

The full list of every action and filter, with parameters, lives in the hooks reference.

Withdrawal requests

A withdrawal request is a customer asking for money out. The plugin records the request and reserves the funds, but it never transmits money: staff pay out externally, then mark the request transferred. The whole feature is built on the hold primitive, so the money-is-sacred guarantees hold unchanged.

  • Creating a request places a hold for the amount. The money stops being spendable (available balance drops) while the ledger balance stays put, because nothing has left.
  • Marking a request transferred captures the hold into a withdrawal debit, idempotently. The debit is the full requested amount; any fee and the net paid out are recorded in the transaction note and meta, never as separate money movements.
  • Rejecting or cancelling releases the hold. The balance never moved and the ledger shows nothing, because nothing happened.

The service lives on the container. Terminal actions are settled by the ledger, so the first transfer, reject, or cancel wins and the loser is refused.

add_action( 'rb_elite_balance_loaded', function () {
	$withdrawals = rb_elite_balance()->get( 'withdrawals' );

	$request = $withdrawals->create( array(
		'user_id' => 42,
		'amount'  => '50.00',
		'method'  => 'bank_transfer',
		'details' => 'IBAN ...', // payout coordinates; managers only, never in URLs
	) );

	// After paying out externally:
	$withdrawals->transfer( $request->id(), array(
		'reference' => 'BANK-2026-07-0042', // required
		'fee'       => '2.00',              // 0 <= fee < amount; deducted from the amount
	) );

	// Or, instead of transferring:
	// $withdrawals->reject( $request->id(), array( 'reason' => '...' ) ); // required reason
	// $withdrawals->cancel( $request->id(), 42 );                         // customer's own request only
} );

One active (pending or processing) request per customer is enforced under the wallet lock, and a request above the available balance is refused at submission time. The withdrawal transaction type is a core type whose note is customer-visible; the composed note is filterable via rb_elite_balance_withdrawal_transfer_note.

For reading, the service offers get( $id ), for_user( $user_id ), active_for_user( $user_id ), and the admin-list pair query( $args ) / count_for( $args ) with the same filters (status, statuses, user_id, and a customer search matching name, login, email, or ID). mark_processing( $id ) is bookkeeping between pending and the terminal actions. Transfers replay safely: if a retry supplies different values after a crash, the request keeps the fee, net, and reference the ledger recorded first.

Payout details are blanked 30 days after a request reaches a terminal state (filter rb_elite_balance_withdrawal_details_retention_days); read them before the window closes if an integration needs them, and treat them as PII everywhere.

The earning engine

Earning rules award wallet credit on five store moments: order cashback, top-up bonus, signup, review, and birthday. Three core types carry the movements: cashback (order cashback and top-up bonuses) and reward (signup, review, birthday) as credits, earn_reversal as the debit, all with code-composed, customer-visible notes. Every award writes meta.rule_id, so per-rule reporting is a ledger query.

The guarantees are the ledger’s own:

  • At most once: every award has a deterministic key - earn:{rule}:order:{order_id} for cashback, :topup:{order_id}, :signup:{user_id}, :review:{comment_id}, and :birthday:{user_id}:{year} (a birthday awards at most once per calendar year by construction). Duplicate status transitions, gateway retries, and re-approvals replay instead of double-crediting.
  • No retroactivity: a rule acts only on events after it was (last) enabled. Enabling a rule never scans history, and re-enabling never picks up the disabled period.
  • Auto-reversal (order cashback): when the earning order is refunded or cancelled, the award reverses. Partial refunds reverse proportionally to the refunded fraction of the eligible base, one row per refund record (earn:{rule}:order:{id}:refund:{refund_id}); cancellation or full refund sweeps the remainder (earn:{rule}:order:{id}:reverse). If the customer already spent the credit, the engine reverses what the available balance covers and flags the shortfall on the reversal row’s meta, the order notes, and the log - never a negative balance, never silent. Review and top-up awards do not auto-reverse (documented; staff debit manually with a reason when needed).

Trigger notes: signup fires on registration and, without an explicit roles condition, only for the customer role; review requires an approved review by a logged-in customer with a verified purchase of that product; the top-up bonus rides the idempotent top-up credit (its base is the credited amount, so percent rules work); birthdays come from the opt-in Account details field (user meta rb_elbal_birthday, gated behind an explicit admin setting, changeable once per year) and a daily scan in the site’s timezone that honors February 29 birthdays on February 28 in non-leap years.

The eligible base for order cashback is the items subtotal after discounts, excluding shipping and taxes, minus any pay-by-wallet discount on the order (the v1 stacking policy: the discount applies to the order, cashback computes on the discounted base, reward moments are independent); adjust it with the rb_elite_balance_earn_base filter. React to awards and reversals with rb_elite_balance_earn_awarded and rb_elite_balance_earn_reversed (post-commit, never on replays; the third parameter is the earning order, or null for the orderless triggers). Rules themselves are managed on the Earning tab; from code, the container’s earn_rules service exposes create(), update(), delete() (refused once a rule has awarded), and all().

Stuck, or found a gap in these docs? Tell us and a human who works on the plugin answers.

Contact support →