self::HOOK, 'status' => $statuses, 'per_page' => -1, ], 'ids' ); if ( empty( $action_ids ) ) { return 0; } $cancelled = 0; foreach ( $action_ids as $action_id ) { if ( ! class_exists( 'ActionScheduler' ) ) { break; // Can't reach the store API; nothing more to do. } try { ActionScheduler::store()->cancel_action( $action_id ); $cancelled++; } catch ( Exception $e ) { // Already gone or store-specific error — nothing more we // can safely do for this ID, move on to the rest. continue; } } wc_get_logger()->info( sprintf( 'ACH Processing: purged %d stale scheduled action(s) for %s.', $cancelled, self::HOOK ), [ 'source' => 'ach-processing' ] ); return $cancelled; } /** * Timestamp of the next upcoming RUN_HOURS slot (today or tomorrow), in * the site's configured timezone. Anchoring to real clock times keeps * every run landing at the same times each day, rather than drifting * based on when the site happened to first schedule it. */ private static function next_run_timestamp() { $tz = wp_timezone(); $now = new DateTime( 'now', $tz ); $candidates = []; foreach ( self::RUN_HOURS as $hour ) { foreach ( [ 'today', 'tomorrow' ] as $day ) { $t = new DateTime( $day, $tz ); $t->setTime( $hour, 0, 0 ); if ( $t > $now ) { $candidates[] = $t->getTimestamp(); } } } sort( $candidates ); return $candidates[0] ?? ( $now->getTimestamp() + DAY_IN_SECONDS ); } /** * Returns diagnostic counts so the manual-trigger AJAX path can show * ops what the poll actually did. The recurring Action Scheduler call * ignores the return value. * * @param bool $force Bypass the MIN_SECONDS_BETWEEN_RUNS floor. Used by * the admin's manual "run poll now" button, which * should always execute on demand regardless of * when the last scheduled run happened. * @return array{skipped_too_soon?:bool,candidates:int,refs:int,rows:int,updated:int,unchanged:int,unmapped:int,no_match:int,update_failed:int,empty_ref:int} */ public static function run( $force = false ) { $logger = wc_get_logger(); $ctx = [ 'source' => 'ach-processing' ]; // Queue up the next occurrence immediately, before anything below // can early-return or throw. This is what keeps the daily chain // alive (see class docblock) — done first, unconditionally for // real scheduled executions, so a floor-skip or an error later in // this method can't break the schedule. The manual admin button // ($force=true) never touches the schedule at all. if ( ! $force ) { self::schedule_next_run(); } // Hard safety floor: even if Action Scheduler ends up with // duplicate/overlapping scheduled actions for this hook (see class // docblock), don't let the upstream API actually get hit more than // once every MIN_SECONDS_BETWEEN_RUNS. The manual admin button // passes $force=true to skip this. $last_run = (int) get_option( 'achp_poll_last_run', 0 ); if ( ! $force && $last_run && ( time() - $last_run ) < self::MIN_SECONDS_BETWEEN_RUNS ) { $logger->info( sprintf( 'Status poll skipped: last run was %d seconds ago (floor is %d).', time() - $last_run, self::MIN_SECONDS_BETWEEN_RUNS ), $ctx ); return [ 'skipped_too_soon' => true ]; } update_option( 'achp_poll_last_run', time(), false ); $stats = [ 'candidates' => 0, 'refs' => 0, 'rows' => 0, 'updated' => 0, 'unchanged' => 0, 'unmapped' => 0, 'no_match' => 0, 'update_failed' => 0, 'empty_ref' => 0, ]; // Includes 'processing' and 'completed' in addition to the usual // pre-confirmation statuses, for two reasons: // // 1. Retroactive self-correction: any order that reached 'At SB' // before the mapping fix (when it incorrectly went to // 'processing' instead of 'on-hold') gets re-checked against its // real current upstream status here and corrected automatically // — no separate migration script needed, since this always // checks live upstream status rather than trusting local state. // 2. Late returns: an ACH transaction can still be returned/reversed // by the bank after it looked fully processed (or even // 'completed'). Excluding those statuses would mean a return // landing after fulfillment goes undetected forever. $orders = wc_get_orders( [ 'limit' => self::MAX_ORDERS, 'status' => [ 'pending', 'on-hold', 'processing', 'completed' ], 'meta_key' => 'custom_transaction_id', 'meta_compare' => 'EXISTS', 'date_created' => '>' . ( time() - ( self::LOOKBACK_DAYS * DAY_IN_SECONDS ) ), 'orderby' => 'date', 'order' => 'DESC', ] ); $stats['candidates'] = is_array( $orders ) ? count( $orders ) : 0; $refs = []; foreach ( (array) $orders as $order ) { $ref = $order->get_meta( 'custom_transaction_id', true ); if ( '' !== $ref ) { $refs[ $ref ] = $order; } } $stats['refs'] = count( $refs ); $logger->info( sprintf( 'Status poll start: %d candidate orders, %d with non-empty refs.', $stats['candidates'], $stats['refs'] ), $ctx ); if ( empty( $refs ) ) { return $stats; } foreach ( array_chunk( array_keys( $refs ), self::BATCH_SIZE ) as $batch ) { $remoteData = ACHP_Main_Class::curlStatusPost( $batch ); $rowCount = is_array( $remoteData ) ? count( $remoteData ) : 0; $stats['rows'] += $rowCount; $logger->info( sprintf( 'Status poll batch: sent %d refs, received %d rows.', count( $batch ), $rowCount ), $ctx ); foreach ( (array) $remoteData as $row ) { // Normalize key case at both levels — the API has been seen // to flip between camelCase and PascalCase across endpoints // (`curlPost` already does this for its `status` subkeys). // Without this, `paymentRefID` lookups would silently miss // when the API ships `PaymentRefID`. if ( is_array( $row ) ) { $row = array_change_key_case( $row, CASE_LOWER ); if ( isset( $row['status'] ) && is_array( $row['status'] ) ) { $row['status'] = array_change_key_case( $row['status'], CASE_LOWER ); } } $ref = $row['paymentrefid'] ?? ''; $message = $row['status']['message'] ?? ''; if ( '' === (string) $ref ) { $stats['empty_ref']++; $logger->warning( sprintf( 'Row missing paymentRefID after normalization: %s', wp_json_encode( $row ) ), $ctx ); continue; } $order = $refs[ $ref ] ?? null; if ( ! $order ) { $stats['no_match']++; $logger->warning( sprintf( 'Row ref=%s did not match any candidate order. Row: %s', $ref, wp_json_encode( $row ) ), $ctx ); continue; } $outcome = self::apply_status( $order, $message ); if ( isset( $stats[ $outcome ] ) ) { $stats[ $outcome ]++; } $logger->info( sprintf( 'Row: ref=%s order=#%d current=%s msg=%s -> %s', $ref, $order->get_id(), $order->get_status(), '' === (string) $message ? '(empty)' : $message, $outcome ), $ctx ); } } $logger->info( sprintf( 'Status poll done: candidates=%d refs=%d rows=%d updated=%d unchanged=%d unmapped=%d no_match=%d update_failed=%d empty_ref=%d', $stats['candidates'], $stats['refs'], $stats['rows'], $stats['updated'], $stats['unchanged'], $stats['unmapped'], $stats['no_match'], $stats['update_failed'], $stats['empty_ref'] ), $ctx ); return $stats; } /** * @return string 'updated'|'unchanged'|'unmapped'|'update_failed' */ private static function apply_status( WC_Order $order, $message ) { $message = (string) $message; $map = [ // 'New' is the processor's status for a transaction that was // just submitted and hasn't been picked up for processing yet. // It must map to 'on-hold', not 'pending' — 'pending' is // WooCommerce's own "needs payment" status and re-triggers the // Pay/Cancel buttons on the order-received and My Account // pages, which is exactly the confusing double-payment issue // this plugin needs to avoid. 'New' is not meaningfully // different from 'Pending'/'Hold'/'SB Hold' below — all four // mean "submitted, awaiting confirmation" from the customer's // perspective. 'New' => 'on-hold', 'Pending' => 'on-hold', 'Hold' => 'on-hold', 'SB Hold' => 'on-hold', // 'At SB' means the transaction has reached the processor's // clearinghouse step but funds haven't actually settled yet — // per ACH Processing's status documentation, this is still an // awaiting-confirmation state, not a confirmed payment. Keeping // it on 'on-hold' (rather than moving to 'processing' early) // avoids marking orders as paid/ready-to-fulfill before the // transaction has actually cleared. 'At SB' => 'on-hold', 'File Transmitted' => 'processing', 'SB Remit' => 'processing', 'Returned' => 'failed', 'Deleted' => 'cancelled', 'SB Void' => 'cancelled', ]; // Unknown upstream statuses must not transition the order — silently // demoting a 'processing' order to 'on-hold' on an unrecognised // message would mask real bugs. Log once for ops to investigate. if ( ! isset( $map[ $message ] ) ) { wc_get_logger()->warning( sprintf( 'Unmapped ACH status "%s" for order #%d.', $message, $order->get_id() ), [ 'source' => 'ach-processing' ] ); return 'unmapped'; } $target = $map[ $message ]; $previous_status = $order->get_status(); if ( $previous_status === $target ) { return 'unchanged'; } // Never regress a 'completed' order back to an earlier // pre-fulfillment stage just because the processor is still // reporting an older intermediate status (e.g. 'File Transmitted' // or 'SB Remit', neither of which the processor ever "graduates" // away from on its own). The processor's feed has no concept of // WooCommerce fulfillment state, so a 'completed' order should only // ever move forward to a genuine negative outcome — a late return // or void — never backward to 'on-hold'/'processing'. if ( 'completed' === $previous_status && ! in_array( $target, [ 'failed', 'cancelled' ], true ) ) { return 'unchanged'; } // A return/reversal landing on an order that had already reached // 'processing' or 'completed' is a materially different event than // a routine pre-confirmation status change — the order may already // be fulfilled/shipped, and this represents an actual loss that // needs a human to look at it, not just a quiet status change. $is_late_return = 'failed' === $target && in_array( $previous_status, [ 'processing', 'completed' ], true ); $order->update_status( $target, sprintf( 'ACH Processing status: %s', $message ) ); // Re-read the order to confirm the transition actually persisted — // catches silent failures from filters that veto the change or from // HPOS/data-store misconfiguration. $reloaded = wc_get_order( $order->get_id() ); if ( ! $reloaded || $reloaded->get_status() !== $target ) { wc_get_logger()->error( sprintf( 'Status update did not persist for order #%d: expected "%s", still "%s" after update_status().', $order->get_id(), $target, $reloaded ? $reloaded->get_status() : '(no order)' ), [ 'source' => 'ach-processing' ] ); return 'update_failed'; } if ( $is_late_return ) { self::notify_late_return( $reloaded, $previous_status ); } return 'updated'; } /** * Emails the store admin when a payment is returned/reversed after the * order had already reached 'processing' or 'completed' — i.e. after it * likely shipped or was otherwise fulfilled. This is intentionally * separate from the routine order note WooCommerce already logs via * update_status(), since a loss on an already-fulfilled order needs a * human to see it promptly, not just show up in the order's history. */ private static function notify_late_return( WC_Order $order, $previous_status ) { $admin_email = get_option( 'admin_email' ); if ( empty( $admin_email ) ) { return; } $subject = sprintf( '[%s] ACH payment returned after order was already %s — Order #%d', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $previous_status, $order->get_id() ); $body = sprintf( "Order #%d's ACH payment was returned by the bank after the order had already reached \"%s\".\n\n" . "This likely means the order was fulfilled/shipped before the payment failed, and may need manual follow-up (e.g. contacting the customer, adjusting inventory, or pursuing the balance).\n\n" . "Order total: %s\nCustomer: %s\nView order: %s\n", $order->get_id(), $previous_status, wp_strip_all_tags( wc_price( $order->get_total() ) ), $order->get_formatted_billing_full_name(), $order->get_edit_order_url() ); wp_mail( $admin_email, $subject, $body ); wc_get_logger()->warning( sprintf( 'Late return: order #%d was "%s" before reversal, admin notified at %s.', $order->get_id(), $previous_status, $admin_email ), [ 'source' => 'ach-processing' ] ); } }