Skip to content

Payouts reference

The Ignition Payouts add-on records disbursements a platform operator has already made to a creator, and what each one settles. It moves no money: every payout is a record of a payment made outside WordPress.

It exposes the Payout as a first-class entity — a filterable transitions map, creation and status-change actions, and a read API — so another add-on can react to a payout or extend how one is recorded.

The add-on refuses to bootstrap unless both hold, and shows a notice-error naming the one that failed:

Constant Floor Enforced
IGCF_PAYOUTS_REQUIRES_CORE 0.23.0 Always.
IGCF_PAYOUTS_REQUIRES_PLATFORM 0.4.0 Always — igcf-platform must also be active. An absent Platform Mode is below the floor, not exempt from it.

It bootstraps on plugins_loaded at priority 30, later than igcf-platform, so the Platform Mode constant is defined by the time the gate reads it.

do_action( 'igcf_payouts_payout_created', int $payout_id, int $actor_id );

Fires after a payout row and all of its link rows have been committed. The actor resolves as $args['actor_id'], then get_current_user_id(), then 0 for a system context.

This is the only creation signal. Recording a payout writes its '' → paid audit row directly and never runs a transition, so igcf_payouts_payout_paid does not fire on creation. The contra row written by a reversal does not fire this action either — subscribe to the transition actions for that.

Every status change fires three actions, in this order:

do_action( 'igcf_payouts_payout_status_changed', int $payout_id, string $old_status, string $new_status, int $actor_id );
do_action( "igcf_payouts_payout_{$new_status}", int $payout_id, int $actor_id );
do_action( "igcf_payouts_payout_{$old_status}_to_{$new_status}", int $payout_id, int $actor_id );

The same shape core gives its own entities — a catch-all, a “reached this status” hook, and a directional one. Concrete names under the shipped transition map: igcf_payouts_payout_paid, igcf_payouts_payout_failed, igcf_payouts_payout_reversed, igcf_payouts_payout_pending_to_paid, igcf_payouts_payout_pending_to_failed, igcf_payouts_payout_paid_to_reversed.

All three fire only after both the row update and the audit-log insert have succeeded, so a subscriber never sees a change that was rolled back. A same-status call is idempotent and fires nothing.

What actually fires in 0.1.0. The only status a payout is born with is paid, and the only transition any shipped code path performs is paid → reversed. So igcf_payouts_payout_created, igcf_payouts_payout_status_changed, igcf_payouts_payout_reversed and igcf_payouts_payout_paid_to_reversed fire; the pending and failed legs exist in the map for the later send-money path and never fire on their own.

apply_filters( 'igcf_payouts_payout_transitions', array $transitions );

The legal transition map, status => array of allowed next statuses. Consulted before every write; adding an entry here is the only supported way to widen what a payout may do. Ships as:

array(
'pending' => array( 'paid', 'failed' ),
'paid' => array( 'reversed' ),
'failed' => array(),
'reversed' => array(),
)
apply_filters( 'igcf_payouts_payout_statuses', array $statuses );

status slug => translated label. Ships with pending, paid, failed, reversed. Add a status here and to the transitions map together — one without the other is not reachable.

apply_filters( 'igcf_payouts_payout_methods', array $methods );

method slug => translated label. Ships with offline_check, offline_bank_transfer, offline_cash, offline_other. Creation validates the submitted method against this registry, so a gateway add-on adding a send-money rail must register its key here before a payout can carry it.

apply_filters( 'igcf_payouts_payout_before_create', array $data, array $context );

The column data about to be inserted, filtered after all validation and before the transaction opens. $context carries recipient_user_id, amount and attempt_ids as the caller passed them.

Four keys are re-asserted immediately after the filter and cannot be overridden: recipient_user_id, currency, mode and status. The first three are derived from the covered payment attempts rather than taken on trust; status is hard-coded, so a payout is always created paid. amount, computed_amount, method, reference, private_note, paid_at and the timestamps are yours to change. Return an array — there is no type guard.

apply_filters( "igcf_payouts_payout_before_{$new_status}", null, int $payout_id, int $actor_id );

A veto. Return a WP_Error to refuse the transition; it is returned to the caller verbatim and nothing is written. Fires only for a transition the map already permits, and before the update, so a veto leaves no row change and no audit entry.

apply_filters( 'igcf_payouts_payout_query_args', array $args );

Applied to every payout query before parsing. Filtered values still pass through the orderby allowlist, the order allowlist and the per-page clamp.

Note that the History screen’s summary totals are computed from the screen’s own filters, not through this hook. An add-on that narrows the ledger here moves the rows without moving the total above them.

igcf_db_in_transaction and igcf_db_server_reports_transaction_state are applied by this add-on’s write paths exactly as core’s refund service applies them. They are the same hook names — a subscriber affects both.

Igcf_Payouts_Payout_Service is append-only: there is no update() and no delete(). All methods are public static; amounts are in minor units.

Method Returns
get( $id ) One Igcf_Payouts_Payout, or null.
query( array $args ) array{ payouts, total, pages }. Args: recipient_user_id, status (string or array), mode, currency, payment_attempt_id, include_reversals (default true), date_query with after/before on paid_at, orderby (paid_at|created_at|amount|status), order, per_page (clamped 1–100), page.
get_covered_attempt_ids( $payout_id ) Every payment-attempt ID this payout links to, whatever its status.
covered_attempt_ids( array $attempt_ids ) Bulk reverse lookup, attempt_id => payout_id, restricted to coverage that is still in force. Use this rather than one lookup per attempt.
get_reversal( $payout_id ) The contra row that reversed this payout, or null.
get_status_log( $payout_id ) The append-only audit trail, oldest first.
get_statuses() / get_status_label( $status ) The status registry and one label.
get_transitions() The filtered transition map.
get_methods() / get_method_label( $method ) The method registry and one label.

“In force” means status = 'paid' and not itself a reversal. Every coverage query in the add-on uses that definition, so a reversed payout stops covering anything and its payments become payable again.

Method Behavior
create( $recipient_user_id, $amount, array $attempt_ids, array $args ) Records a payout that has already been made. Always creates it paid and takes no status argument. Returns the new payout ID or a WP_Error. Optional $args: computed_amount (defaults to $amount), currency, method (defaults to offline_other), reference, private_note, paid_at, note, actor_id.
transition_status( $payout_id, $new_status, $actor_id, array $args ) Moves a payout along the map. $args: failure_code, note, defer_hooks. Returns array{ status, statusLabel, message }, plus idempotent => true on a no-op. It uses the $actor_id you pass verbatim — it does not resolve one.
reverse( $payout_id, array $args ) Reverses a payout. $args: reason, actor_id. Returns the contra row’s ID or a WP_Error.

create() derives the recipient, currency and mode from the covered attempts — the campaign’s author, the pledge’s currency, the pledge’s mode — and refuses when the caller’s arguments disagree, when the batch mixes recipients, currencies or modes, when an attempt has not succeeded, or when an in-force payout already covers one of them. Error codes are namespaced igcf_payout_*; the full set is documented on the method.

Calling reverse() twice is safe: the second call finds the existing contra row and returns its ID rather than creating a second one. A unique key on the reversal pointer enforces one reversal per payout at the database level.

Both a status change and a new row, in one transaction:

  • The original transitions paid → reversed and keeps every one of its values.
  • A contra row is inserted into the same table, carrying the same recipient, amount, currency, mode and method, with status = 'reversed' and a non-null pointer at the original. The amount is stored positive — the sign is implied by the pointer, and it is the reader’s job to negate it.
  • The contra row is linked to no payment attempts and writes no audit entry of its own. The single audit entry is the original’s paid → reversed.
  • Refunds the reversed payout had absorbed are not un-linked, but they stop counting: absorption is also tested against in-force payouts, so the refund stands again and the next payout absorbs it.

Contra rows would double-count in any total, which is what query()’s include_reversals argument is for.

Seam How it is used
igcf_payment_attempt_operator_still_holds Answered. Returns false for a payment an in-force payout already covers; every other case passes the incoming value through. The add-on never overrides core’s stamp — it only reports money that has since gone out.
igcf_payment_attempt_net_to_creator Read as one input, never answered. Core documents that value as a display figure computed before refunds; this add-on subtracts recorded refunds and already-covered attempts to turn it into an amount to pay.
igcf_pledge_detail_attempt_columns / igcf_pledge_detail_attempt_cells Answered with the Payout column on the wp-admin pledge detail page. The column is contributed only when the pledge has a succeeded attempt and money the operator holds; on any other pledge the table is byte-identical to a core-only install. Deliberately not a settlement term — a payout happens later and spans many pledges, so it would break the settlement arithmetic.
Igcf_Payment_Attempt_Service::operator_custody_per_attempt() The bulk custody read behind the queue and the pledge column.
Igcf_Refund_Service::total_refunded_per_attempt() Per-attempt refund totals, in bulk, so the queue does not do one lookup per row.
Igcf_Pledge_Service::get_processing_fees_for_pledges() The bulk fee map. Read in bulk rather than through the per-attempt fee filter, because only the map distinguishes an absent fee from a reported zero — which is exactly what the unreliable-figure mark keys on.
Igcf_Campaign_Service::compute_result() The funding verdict behind Ready to Pay versus Held Back. Never re-derived locally.
Igcf_Currencies Validation, exponent, symbol and position. A payout is one recipient in one currency.
Igcf_View_Mode Resolved once per screen and threaded through every link, field and query. Test money never reaches a live queue or a live total.
Igcf_Dashboard_Nav_Registry::register_item() The creator’s Payouts dashboard section, at priority 15 and gated on edit_igcf_campaigns.
igcf_locate_template Resolves the igcf-payouts/ template namespace. A theme override still wins — core’s own search runs first.
The email framework (Igcf_Email, Igcf_Email_Registry, Igcf_Merge_Tags, igcf_notification_categories, igcf_email_preview_data) The payout_recorded email, its payout merge-tag context, its creator notification category, and its admin preview sample.
igcf_uninstall / igcf_activated Table teardown when core is uninstalled, and table creation when core provisions a site.

Both are overridable from a theme, under yourtheme/ignition-crowdfunding/:

Template Renders
igcf-payouts/dashboard-payouts.php The creator’s Payouts dashboard section. Read-only: no form, no nonce, no JavaScript. It fetches its own data and never reads the payout’s private note.
igcf-payouts/emails/payout-recorded.php The payout-recorded email body, inside core’s email layout.

Five merge tags are registered in the payout context for operators customising the subject or body: {payout_amount}, {payout_date}, {payout_method}, {payout_reference}, {payouts_url}. The default template renders from its own data and uses none of them.

Four custom tables, created together and dropped together — on the add-on’s own uninstall, and on core’s:

Table Row per Holds
{prefix}igcf_payouts_disbursements Payout Recipient, the amount sent and the amount computed, currency, mode, method, status, reference, private note, the reversal pointer, and when it was paid.
{prefix}igcf_payouts_attempts_link Payment settled Which succeeded payment attempts a payout covered. This is what stops a creator being paid twice and what takes a payment out of the queue.
{prefix}igcf_payouts_refunds_link Refund absorbed Which refunds a payout has already netted, so the next basis calculation does not subtract them again.
{prefix}igcf_payouts_status_log Status change Append-only: old status, new status, actor and note. Creation writes an entry whose old status is the empty string.

Both amount and computed_amount are stored on every payout, so a difference between what the operator sent and what the plugin suggested stays visible on the record forever rather than being reconciled away.

Tables self-migrate on an in-place update.