<?php
/**
 * Payout Batch: Action Scheduler integration + async submit handler.
 *
 * Turns "Pay Affiliates" into a safe-to-close-tab operation. Previously the
 * submit flow drove an AJAX-stepping loop from the admin's browser — closing
 * the tab mid-run left payouts half-processed. With this file in place, the
 * submit handler compiles the candidate list, creates the batch row, and
 * enqueues one Action Scheduler action per affiliate. AS workers pick them
 * up in the background. Admin lands on the batch drawer where live status is
 * polled; the admin can close the tab at any point and the batch will still
 * drain to completion.
 *
 * Hooks registered here (must be available in cron/admin-ajax contexts, not
 * just in the admin-only load path):
 *
 *   - `affwp_process_payout_async`              — request-action handler, fires on
 *                                                  the preview form's submit.
 *   - `affwp_process_payout_affiliate`          — AS callback, one per affiliate.
 *   - `affwp_maybe_finalize_payout_batch`       — AS callback, self-reschedules
 *                                                  until all siblings drain, then
 *                                                  fires the completed hook.
 *
 * @package    AffiliateWP
 * @subpackage Payouts
 * @since      2.33.0
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Handles the preview form's "Process Payout" submit for per-affiliate
 * routing. Creates the batch row, enqueues AS actions, redirects to the
 * batch drawer so the admin can watch progress (or close the tab — the
 * batch drains either way).
 *
 * @since 2.33.0
 *
 * @param array $data $_REQUEST payload.
 * @return void
 */
function affwp_process_payout_async_submit( $data ) {

	if ( ! is_admin() ) {
		return;
	}

	if ( ! current_user_can( 'manage_payouts' ) ) {
		wp_die(
			esc_html__( 'You do not have permission to process payouts.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 403 )
		);
	}

	$nonce = isset( $data['affwp_process_payout_async_nonce'] ) ? $data['affwp_process_payout_async_nonce'] : '';
	if ( ! wp_verify_nonce( $nonce, 'affwp_process_payout_async' ) ) {
		wp_die(
			esc_html__( 'Security check failed.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 403 )
		);
	}

	if ( ! function_exists( 'as_enqueue_async_action' ) ) {
		/*
		 * Action Scheduler should always be available (bundled with
		 * AffiliateWP), but fail loudly if something's broken rather than
		 * silently falling back to an unsafe path.
		 */
		wp_die(
			esc_html__( 'Action Scheduler is not available. Cannot process payouts asynchronously.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 500 )
		);
	}

	require_once AFFILIATEWP_PLUGIN_DIR . 'includes/payouts/class-batch-payout-processor.php';

	/*
	 * Filter-signature lock — prevents a rapid double-submit (within the nonce
	 * TTL) from creating two batches racing the same unpaid referrals. The
	 * `affwp_add_payout` referral-link guard already prevents double-paying,
	 * but two parallel batches with the same criteria split the referrals
	 * across both runs and leave a confusing "partial" status on each. The
	 * lock collapses concurrent submits down to one; the second attempt
	 * lands on a warning notice instead of producing a junk batch.
	 *
	 * Keyed off the filter signature so distinct submits (e.g. "Pay all" vs
	 * "Pay affiliate #42") don't block each other.
	 */
	$lock_signature = md5(
		wp_json_encode(
			array(
				'user_name'      => isset( $data['user_name'] ) ? (string) $data['user_name'] : '',
				'minimum'        => isset( $data['minimum'] ) ? (string) $data['minimum'] : '',
				'from'           => isset( $data['from'] ) ? (string) $data['from'] : '',
				'to'             => isset( $data['to'] ) ? (string) $data['to'] : '',
				'bypass_holding' => ! empty( $data['bypass_holding'] ) ? 1 : 0,
			)
		)
	);
	$lock_key = 'affwp_payout_batch_lock_' . $lock_signature;

	$lock_value = get_transient( $lock_key );

	if ( false !== $lock_value ) {

		/*
		 * The lock stores a structured value since 2.34.2: an array with the
		 * batch_id of the batch it protects (0 while the batch row is still
		 * being created). When we can resolve a concrete batch, route the
		 * admin somewhere useful instead of dead-ending on a warning:
		 *
		 *   - Batch finished (or was deleted) → stale lock. Clear it and
		 *     fall through so this submit proceeds normally. Matters on
		 *     dead-cron hosts (#5834): the previous batch may only have
		 *     completed via the drawer's inline drain, well within the
		 *     lock's five-minute TTL.
		 *   - Batch still processing → send the admin to that batch's
		 *     drawer, where they can watch progress (and the drawer's
		 *     polling itself drains the queue on dead-cron hosts).
		 *
		 * Any other value shape — legacy user ID from a pre-2.34.2 lock, or
		 * the batch_id=0 sentinel of a submit still compiling candidates —
		 * keeps the original behavior: warn and bounce back to Payouts.
		 */
		if ( is_array( $lock_value ) && isset( $lock_value['batch_id'] ) && (int) $lock_value['batch_id'] > 0 ) {

			$locked_batch = affwp_get_payout_batch( (int) $lock_value['batch_id'] );

			if ( $locked_batch && 'processing' === strtolower( (string) $locked_batch->status ) ) {
				$redirect = affwp_admin_url(
					'payouts',
					array(
						'tab'          => 'batches',
						'batch_id'     => (int) $locked_batch->batch_id,
						'affwp_notice' => 'payout_batch_locked',
					)
				);
				wp_safe_redirect( $redirect );
				exit;
			}

			// Stale lock — the batch is done (or gone). Recover and proceed.
			delete_transient( $lock_key );
		} else {
			$redirect = affwp_admin_url(
				'payouts',
				array(
					'affwp_notice' => 'payout_batch_locked',
				)
			);
			wp_safe_redirect( $redirect );
			exit;
		}
	}

	/*
	 * Five-minute window covers a typical Pay-All enqueue + AS warm-up.
	 * Set BEFORE enqueue (deliberately — it closes the double-submit race
	 * during slow candidate compilation) with a batch_id=0 sentinel, then
	 * updated with the real batch ID right after the batch is created.
	 */
	set_transient( $lock_key, array( 'batch_id' => 0 ), 5 * MINUTE_IN_SECONDS );

	$result = \AffWP\Payouts\Batch_Payout_Processor::enqueue_async_batch( $data );

	if ( is_wp_error( $result ) ) {
		// Release the lock on empty-batch so the admin can fix filters and retry.
		delete_transient( $lock_key );

		$redirect = affwp_admin_url(
			'payouts',
			array(
				'affwp_notice' => 'payout_batch_empty',
			)
		);
		wp_safe_redirect( $redirect );
		exit;
	}

	/*
	 * Point the lock at the concrete batch (same TTL) so a second submit
	 * within the window can route the admin to this batch's drawer — or
	 * detect the lock as stale once the batch completes.
	 *
	 * @since 2.34.2
	 */
	set_transient( $lock_key, array( 'batch_id' => (int) $result ), 5 * MINUTE_IN_SECONDS );

	/*
	 * Land on the Batches tab with the new batch's drawer auto-opening; it renders status,
	 * total, per-method breakdown, and a polling-aware payouts list. The
	 * affwp_notice=batch_submitted arg fires a confirmation toast on the destination.
	 */
	$redirect = affwp_admin_url(
		'payouts',
		array(
			'tab'          => 'batches',
			'batch_id'     => (int) $result,
			'affwp_notice' => 'batch_submitted',
		)
	);

	wp_safe_redirect( $redirect );
	exit;
}
add_action( 'affwp_process_payout_async', 'affwp_process_payout_async_submit' );

/**
 * Process a "Pay everyone this batch via Store Credit" payout.
 *
 * Unlike PayPal, Stripe, and the Payouts Service, Store Credit ships no
 * synchronous per-method payout handler — it pays purely by crediting the
 * affiliate's balance when referrals flip to 'paid', which the unified batch
 * processor already does. The legacy "Pay everyone via X" form fires
 * `affwp_process_payout_{$payout_method}` (here, `affwp_process_payout_store_credit`)
 * via `affwp_process_new_payout()`. Without a listener that action used to no-op,
 * so the page reloaded and nothing was paid.
 *
 * Hand the batch to the same async pipeline manual and per-affiliate routing use,
 * carrying a store_credit override so every included affiliate is paid via Store
 * Credit regardless of their own configured method. The crediting itself happens
 * in Batch_Payout_Processor::process_affiliate(), which lifts the
 * can_receive_store_credit() gate for the duration of the payout.
 *
 * @since 2.33.0
 *
 * @param string   $start          Referrals start date.
 * @param string   $end            Referrals end date.
 * @param string   $minimum        Minimum payout amount.
 * @param int|bool $affiliate_id   Affiliate ID, or false for all eligible affiliates.
 * @param string   $payout_method  Payout method (always 'store_credit' here).
 * @param bool     $bypass_holding Whether to bypass the referral holding period.
 * @return void
 */
function affwp_process_payout_store_credit_batch( $start, $end, $minimum, $affiliate_id, $payout_method, $bypass_holding ) {

	if ( ! is_admin() || ! current_user_can( 'manage_payouts' ) ) {
		return;
	}

	if ( ! function_exists( 'as_enqueue_async_action' ) ) {
		wp_die(
			esc_html__( 'Action Scheduler is not available. Cannot process payouts.', 'affiliate-wp' ),
			esc_html__( 'Error', 'affiliate-wp' ),
			array( 'response' => 500 )
		);
	}

	require_once AFFILIATEWP_PLUGIN_DIR . 'includes/payouts/class-batch-payout-processor.php';

	$data = array(
		'payout_method'  => 'store_credit',
		'from'           => $start,
		'to'             => $end,
		'minimum'        => $minimum,
		'bypass_holding' => $bypass_holding ? 1 : 0,
	);

	if ( ! empty( $affiliate_id ) ) {
		$data['include_affiliates'] = array( (int) $affiliate_id );
	}

	$result = \AffWP\Payouts\Batch_Payout_Processor::enqueue_async_batch( $data );

	if ( is_wp_error( $result ) ) {
		wp_safe_redirect( affwp_admin_url( 'payouts', array( 'affwp_notice' => 'payout_batch_empty' ) ) );
		exit;
	}

	wp_safe_redirect(
		affwp_admin_url(
			'payouts',
			array(
				'tab'          => 'batches',
				'batch_id'     => (int) $result,
				'affwp_notice' => 'batch_submitted',
			)
		)
	);
	exit;
}
add_action( 'affwp_process_payout_store_credit', 'affwp_process_payout_store_credit_batch', 10, 6 );

/**
 * Action Scheduler callback — processes one affiliate's payout within the
 * given batch. Called once per enqueued action, drained by AS workers
 * independently of any browser session.
 *
 * Idempotent: the underlying `affwp_add_payout()` refuses to create a
 * duplicate payout for referrals already linked. If AS retries a failed
 * action, we don't double-pay.
 *
 * @since 2.33.0
 *
 * @param int $batch_id     Payout batch ID.
 * @param int $affiliate_id Affiliate ID to process. The worker re-resolves
 *                          this affiliate's candidate referrals from the
 *                          batch's stored filter snapshot, so the AS args
 *                          stay tiny regardless of referral count.
 * @return void
 */
function affwp_as_process_payout_affiliate( $batch_id, $affiliate_id ) {

	require_once AFFILIATEWP_PLUGIN_DIR . 'includes/payouts/class-batch-payout-processor.php';

	\AffWP\Payouts\Batch_Payout_Processor::process_affiliate_from_args(
		(int) $batch_id,
		(int) $affiliate_id
	);
}
add_action( 'affwp_process_payout_affiliate', 'affwp_as_process_payout_affiliate', 10, 2 );

/**
 * Action Scheduler callback — checks whether all per-affiliate actions in
 * the batch have drained. If any are still pending or in-progress, this
 * callback reschedules itself with a short delay. Once all siblings are
 * done, it performs the final recount and fires
 * `affwp_batch_generate_payouts_completed` so method integrations (PayPal,
 * Stripe) can submit their async providers.
 *
 * Self-rescheduling avoids the double-finalize race: only this callback
 * (the singleton finalize action) fires the completion hook. Subscribers
 * can still guard via batch status if they want belt-and-suspenders.
 *
 * @since 2.33.0
 *
 * @param int $batch_id Payout batch ID.
 * @return void
 */
function affwp_as_maybe_finalize_payout_batch( $batch_id ) {

	$batch_id = (int) $batch_id;

	if ( $batch_id <= 0 ) {
		return;
	}

	$batch = affwp_get_payout_batch( $batch_id );

	if ( ! $batch || 'processing' !== $batch->status ) {
		// Already finalized (or missing). Nothing to do.
		return;
	}

	if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
		// AS missing — shouldn't happen (bundled) but don't loop forever.
		return;
	}

	$pending = as_get_scheduled_actions(
		array(
			'group'    => affwp_payout_batch_as_group( $batch_id ),
			'hook'     => 'affwp_process_payout_affiliate',
			'status'   => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
			'per_page' => 1,
		),
		'ids'
	);

	if ( ! empty( $pending ) ) {
		// Siblings still draining — come back in 15 seconds.
		as_schedule_single_action(
			time() + 15,
			'affwp_maybe_finalize_payout_batch',
			array( $batch_id ),
			affwp_payout_batch_as_group( $batch_id )
		);
		return;
	}

	/*
	 * All per-affiliate actions have resolved. Final authoritative recount
	 * (so any last status updates from in-flight AS steps are captured) and
	 * fire the integration-level completion hook.
	 */
	affiliate_wp()->affiliates->payout_batches->recount( $batch_id );

	/**
	 * Fires once at the end of the batch, after every per-affiliate AS
	 * action has resolved and the batch has been fully recounted.
	 *
	 * Method integrations (PayPal, Stripe) hook this to submit their async
	 * providers in one API call with the complete set of method-matched
	 * payouts, and to schedule any verification work.
	 *
	 * @since 2.33.0
	 *
	 * @param int $batch_id Payout batch ID.
	 */
	do_action( 'affwp_batch_generate_payouts_completed', $batch_id );
}
add_action( 'affwp_maybe_finalize_payout_batch', 'affwp_as_maybe_finalize_payout_batch', 10, 1 );

/**
 * Returns the AS group name for a batch — the group is scoped per-batch so
 * concurrent batches' actions don't interfere, and the finalize callback
 * can query pending siblings in its own group only.
 *
 * Duplicated here as a free function (not a class static) so it's
 * reachable from the AS callback closure above without loading the
 * processor class.
 *
 * @since 2.33.0
 *
 * @param int $batch_id Payout batch ID.
 * @return string
 */
function affwp_payout_batch_as_group( $batch_id ) {
	return 'affwp-payout-batch-' . (int) $batch_id;
}

/**
 * Drains a payout batch's Action Scheduler queue inline, under a time budget.
 *
 * On hosts where WP-Cron never fires and the AS async (loopback) runner is
 * blocked, enqueued batch actions sit in `pending` forever and the batch is
 * stuck in Processing with no CSV. This helper lets any live request — in
 * practice the batch drawer's status poll — claim and run the batch's own
 * actions directly, so the batch drains while the admin is watching it, even
 * with a completely dead cron.
 *
 * Safe alongside real AS runners: `stake_claim()` is the same claim
 * mechanism cron/WP-CLI workers use, so an action is only ever executed by
 * one claimant, and the claim is always released when we're done.
 *
 * @since 2.34.2
 *
 * @param int $batch_id    Payout batch ID.
 * @param int $time_budget Max seconds to spend processing actions. Default 5.
 * @return int Number of actions processed.
 */
function affwp_drain_payout_batch_queue( int $batch_id, int $time_budget = 5 ): int {

	if ( $batch_id <= 0 ) {
		return 0;
	}

	// AS must be fully initialized (store + runner) before we can claim work.
	if ( ! class_exists( 'ActionScheduler' ) || ! \ActionScheduler::is_initialized() ) {
		return 0;
	}

	$store  = \ActionScheduler::store();
	$runner = \ActionScheduler::runner();

	if ( ! $store instanceof \ActionScheduler_Store || ! $runner instanceof \ActionScheduler_Abstract_QueueRunner ) {
		return 0;
	}

	/*
	 * Recover zombie actions FIRST. On a dead-cron site nothing ever runs
	 * AS's periodic cleanup, so an action killed mid-run by a hard PHP
	 * timeout stays claimed/in-progress forever — unclaimable by
	 * stake_claim() — and the batch polls infinitely. reset_timeouts()
	 * returns timed-out claimed-but-pending actions to the claimable pool;
	 * mark_failures() flips long-dead in-progress actions to failed so the
	 * finalize checker can resolve the batch.
	 */
	$cleaner = new \ActionScheduler_QueueCleaner( $store );
	$cleaner->reset_timeouts();
	$cleaner->mark_failures();

	$group     = affwp_payout_batch_as_group( $batch_id );
	$deadline  = microtime( true ) + max( 1, $time_budget );
	$processed = 0;

	/*
	 * HybridStore compatibility. Sites still mid AS 2.x→3.x data migration
	 * run the HybridStore — and since the migration itself is driven by
	 * scheduled actions, a dead-cron site can be stuck on it indefinitely,
	 * i.e. exactly the hosts this drain targets. HybridStore::stake_claim()
	 * consults the legacy post store FIRST, and that store throws
	 * InvalidArgumentException when the group has no taxonomy term — before
	 * the primary DB store (where our actions actually live) is ever asked.
	 * Pre-creating the term makes the legacy claim return empty and fall
	 * through to the DB store.
	 */
	if ( $store instanceof \ActionScheduler_HybridStore
		&& taxonomy_exists( \ActionScheduler_wpPostStore::GROUP_TAXONOMY )
		&& ! term_exists( $group, \ActionScheduler_wpPostStore::GROUP_TAXONOMY )
	) {
		wp_insert_term( $group, \ActionScheduler_wpPostStore::GROUP_TAXONOMY );
	}

	while ( microtime( true ) < $deadline ) {

		$claim = null;

		try {
			// Claim only THIS batch's due actions — other groups are untouched.
			$claim      = $store->stake_claim( 10, null, array(), $group );
			$action_ids = $claim->get_actions();

			if ( empty( $action_ids ) ) {
				// Nothing claimable right now (e.g. finalize not due yet).
				break;
			}

			foreach ( $action_ids as $action_id ) {
				// process_action() handles per-action failures internally.
				$runner->process_action( $action_id, 'AffiliateWP Batch Drawer' );
				$processed++;

				if ( microtime( true ) >= $deadline ) {
					break;
				}
			}
		} catch ( \InvalidArgumentException $e ) {
			// stake_claim() throws this when the group doesn't exist —
			// i.e. every action in the batch's group is already done and
			// AS has nothing registered under it. Treat as nothing pending.
			break;
		} catch ( \RuntimeException $e ) {
			// Database error while claiming — don't retry within this request.
			affiliate_wp()->utils->log( sprintf( 'Payout batch inline drain: claim failed for batch %d: %s', $batch_id, $e->getMessage() ) );
			break;
		} finally {
			// Always release the claim so unprocessed actions return to the
			// pool instead of becoming zombies of our own making.
			if ( $claim instanceof \ActionScheduler_ActionClaim ) {
				$store->release_claim( $claim );
			}
		}
	}

	return $processed;
}

/**
 * Checks whether a payout batch still has pending or in-flight Action
 * Scheduler actions in its group.
 *
 * Deliberately NOT filtered by hook: the batch group contains both the
 * per-affiliate worker actions (`affwp_process_payout_affiliate`) and the
 * finalize checker (`affwp_maybe_finalize_payout_batch`), and the drawer
 * must keep polling until the finalize action — scheduled ~15s out — has
 * also run, or the completion hook never fires on dead-cron sites.
 *
 * @since 2.34.2
 *
 * @param int $batch_id Payout batch ID.
 * @return bool True when at least one action in the batch group is pending or running.
 */
function affwp_payout_batch_has_pending_actions( int $batch_id ): bool {

	if ( $batch_id <= 0 || ! function_exists( 'as_get_scheduled_actions' ) ) {
		return false;
	}

	$pending = as_get_scheduled_actions(
		array(
			'group'    => affwp_payout_batch_as_group( $batch_id ),
			'status'   => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
			'per_page' => 1,
		),
		'ids'
	);

	return ! empty( $pending );
}

/**
 * Detects a wedged batch queue: pending actions that have made zero progress
 * for longer than the staleness window.
 *
 * Used by the drawer poll to decide whether to keep overriding a
 * prematurely-terminal batch status back to "processing" (recount() derives
 * status purely from existing payout rows, so a manual batch reads
 * `completed` after its first worker even while siblings are still queued).
 * Without this cap, a genuinely stuck queue — e.g. an action scheduled far
 * in the future that can never be claimed — would keep the drawer polling
 * forever.
 *
 * Progress is fingerprinted from the group's outstanding action IDs plus the
 * batch's processed/failed counters; any change resets the window. State is
 * kept in a short-lived transient keyed by batch.
 *
 * @since 2.34.2
 *
 * @param int $batch_id Payout batch ID.
 * @param int $window   Seconds of zero progress before the queue counts as stale. Default 600 (10 minutes).
 * @return bool True when the batch queue has been stalled for longer than the window.
 */
function affwp_payout_batch_drain_is_stale( int $batch_id, int $window = 600 ): bool {

	if ( $batch_id <= 0 || ! function_exists( 'as_get_scheduled_actions' ) ) {
		return false;
	}

	$outstanding = as_get_scheduled_actions(
		array(
			'group'    => affwp_payout_batch_as_group( $batch_id ),
			'status'   => array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ),
			'per_page' => 100,
		),
		'ids'
	);

	$batch = affwp_get_payout_batch( $batch_id );

	$fingerprint = md5(
		wp_json_encode(
			array(
				'actions'   => array_map( 'intval', (array) $outstanding ),
				'processed' => $batch ? (int) $batch->processed_count : 0,
				'failed'    => $batch ? (int) $batch->failed_count : 0,
			)
		)
	);

	$key   = 'affwp_batch_drain_watch_' . $batch_id;
	$watch = get_transient( $key );

	if ( ! is_array( $watch ) || ( $watch['fingerprint'] ?? '' ) !== $fingerprint ) {
		// Progress since the last poll (or first observation) — restart the window.
		set_transient(
			$key,
			array(
				'fingerprint' => $fingerprint,
				'time'        => time(),
			),
			HOUR_IN_SECONDS
		);
		return false;
	}

	return ( time() - (int) ( $watch['time'] ?? 0 ) ) > $window;
}

/**
 * Watchdog callback — re-arms `affwp_maybe_finalize_payout_batch` for any batch
 * stuck in `processing` past the grace window, where no finalize action is
 * currently queued. Runs on a 15-minute recurring schedule.
 *
 * @since 2.33.0
 *
 * @return void
 */
function affwp_as_payout_batch_watchdog() {
	global $wpdb;

	if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_schedule_single_action' ) ) {
		return;
	}

	$table        = affiliate_wp()->affiliates->payout_batches->table_name;
	$grace_cutoff = gmdate( 'Y-m-d H:i:s', time() - 15 * MINUTE_IN_SECONDS );

	$stuck_ids = $wpdb->get_col(
		$wpdb->prepare(
			"SELECT batch_id FROM {$table}
			 WHERE status = %s AND date_created < %s
			 LIMIT 50",
			'processing',
			$grace_cutoff
		)
	);

	if ( empty( $stuck_ids ) ) {
		return;
	}

	foreach ( $stuck_ids as $batch_id ) {
		$batch_id = (int) $batch_id;
		if ( $batch_id <= 0 ) {
			continue;
		}

		$group = affwp_payout_batch_as_group( $batch_id );

		if ( as_has_scheduled_action( 'affwp_maybe_finalize_payout_batch', array( $batch_id ), $group ) ) {
			continue;
		}

		as_schedule_single_action(
			time(),
			'affwp_maybe_finalize_payout_batch',
			array( $batch_id ),
			$group
		);

		affiliate_wp()->utils->log( sprintf( 'Payout batch watchdog: re-armed finalize action for stuck batch %d.', $batch_id ) );
	}
}
add_action( 'affwp_payout_batch_watchdog', 'affwp_as_payout_batch_watchdog' );
