'ach-processing' ]; $stats = [ 'candidates' => 0, 'refs' => 0, 'rows' => 0, 'updated' => 0, 'unchanged' => 0, 'unmapped' => 0, 'no_match' => 0, 'update_failed' => 0, 'empty_ref' => 0, ]; $orders = wc_get_orders( [ 'limit' => self::MAX_ORDERS, 'status' => [ 'pending', 'on-hold' ], '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' => 'pending', 'Pending' => 'on-hold', 'Hold' => 'on-hold', 'SB Hold' => 'on-hold', 'At SB' => 'processing', '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 ]; if ( $order->get_status() === $target ) { return 'unchanged'; } $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'; } return 'updated'; } }