clock->now(); $abandoned = $this->abandon_stale( $now, $limit ); $failed = $this->fail_stuck_converting( $now, $limit ); $deleted = $this->purge_expired( $now, $limit ); $holds_released = $this->release_orphaned_holds( $limit ); return [ 'abandoned' => $abandoned, 'deleted' => $deleted, 'failed' => $failed, 'holds_released' => $holds_released, ]; } /** * Open → abandoned after idle timeout; release linked holds. * * @param DateTimeImmutable $now Instant. * @param int $limit Max. */ private function abandon_stale( DateTimeImmutable $now, int $limit ): int { $before = $now->sub( new DateInterval( 'PT' . self::ABANDON_AFTER_MINUTES . 'M' ) ); $count = 0; foreach ( $this->intents->find_stale_open( $before, $limit ) as $intent ) { if ( null === $intent->id() ) { continue; } $intent->mark_abandoned( $now ); $this->intents->save( $intent ); $this->mirror_cart_status( $intent, CartStatus::Abandoned, $now ); $this->release_holds_for_intent( $intent ); if ( null !== $this->bus ) { $this->bus->dispatch( new IntentAbandoned( (int) $intent->id(), null, $now ) ); } ++$count; } return $count; } /** * Converting → failed after stuck timeout; release holds. * * @param DateTimeImmutable $now Instant. * @param int $limit Max. */ private function fail_stuck_converting( DateTimeImmutable $now, int $limit ): int { $before = $now->sub( new DateInterval( 'PT' . self::STUCK_CONVERTING_MINUTES . 'M' ) ); $count = 0; foreach ( $this->intents->find_stuck_converting( $before, $limit ) as $intent ) { $id = $intent->id(); if ( null === $id ) { continue; } $intent->mark_failed( $now ); $this->intents->save( $intent ); $this->release_holds_for_intent( $intent ); /** * Fires when a converting intent is marked failed after a crash/timeout. * * @param int $intent_id Intent id. * @since 0.1.0 */ do_action( 'bookline/order_intent/stuck_converting', $id ); ++$count; } return $count; } /** * Hard-delete abandoned intents past retention. * * @param DateTimeImmutable $now Instant. * @param int $limit Max. */ private function purge_expired( DateTimeImmutable $now, int $limit ): int { $days = (int) $this->settings->get( 'retention.intent_days' ); if ( $days < 1 ) { $days = 30; } $before = $now->sub( new DateInterval( 'P' . $days . 'D' ) ); $count = 0; foreach ( $this->intents->find_expired_abandoned( $before, $limit ) as $intent ) { $id = $intent->id(); if ( null === $id ) { continue; } $this->intents->delete_cascade( $id ); ++$count; } return $count; } /** * Release soft holds not referenced by any cart item. * * @param int $limit Max holds to inspect. */ private function release_orphaned_holds( int $limit ): int { $candidates = []; foreach ( $this->bookings->find_pending_holds( $limit ) as $booking ) { $id = $booking->id(); if ( null !== $id ) { $candidates[] = $id; } } $orphans = $this->items->filter_unreferenced_hold_ids( $candidates ); $count = 0; foreach ( $orphans as $hold_id ) { try { $this->release->handle( $hold_id, 'abandoned' ); ++$count; } catch ( DomainException ) { continue; } catch ( Throwable ) { continue; } } return $count; } /** * Release holds linked from an intent's cart. * * @param OrderIntent $intent Intent. */ private function release_holds_for_intent( OrderIntent $intent ): void { $cart_id = $intent->cart_id(); if ( null === $cart_id ) { return; } foreach ( $this->items->hold_ids_for_cart( $cart_id ) as $hold_id ) { try { $this->release->handle( $hold_id, 'abandoned' ); } catch ( DomainException ) { continue; } catch ( Throwable ) { continue; } } } /** * Mirror cart status when an intent is abandoned. * * @param OrderIntent $intent Intent. * @param CartStatus $status Status. * @param DateTimeImmutable $now Instant. */ private function mirror_cart_status( OrderIntent $intent, CartStatus $status, DateTimeImmutable $now ): void { $cart_id = $intent->cart_id(); if ( null === $cart_id ) { return; } $cart = $this->carts->find_by_id( $cart_id ); if ( null === $cart ) { return; } $cart->set_status( $status, $now ); $this->carts->save( $cart ); } }