get_meta( '_pokit_enabled' ) ) { return; } $config = pokit_get_config( $obj, true ); if ( ! $config || (int) $config['target_id'] !== $id || 'date' !== $config['trigger'] || ! $config['release_ts'] ) { return; } $when = max( time() + 30, (int) $config['release_ts'] ); as_schedule_single_action( $when, 'pokit_release_target', array( $id ), self::GROUP ); } /** * Action Scheduler callback at the release date. * * @param int $id Target. */ public static function scheduled_release( $id ) { if ( self::processing_disabled() ) { pokit_log( "Release of #{$id} skipped: automatic processing disabled." ); return; } $config = pokit_get_config( $id, true ); if ( ! $config || 'date' !== $config['trigger'] || ! $config['release_ts'] || $config['release_ts'] > time() + 60 ) { return; } $n = self::release_target( $id, array( 'end_preorder' => true ) ); pokit_log( "Released {$n} pre-order(s) for #{$id} on schedule." ); } /** * Hourly sweep: catch dated releases that were missed (e.g. cron down). */ public static function sweep() { if ( self::processing_disabled() ) { return; } foreach ( POKIT_Records::open_targets() as $target ) { $config = pokit_get_config( $target, true ); if ( $config && 'date' === $config['trigger'] && $config['release_ts'] && $config['release_ts'] <= time() ) { self::release_target( $target, array( 'end_preorder' => true ) ); } } // Products whose date passed with no pre-orders at all: switch them back to normal. $ids = get_posts( array( 'post_type' => array( 'product', 'product_variation' ), 'post_status' => 'any', 'posts_per_page' => 50, 'fields' => 'ids', 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery array( 'key' => '_pokit_enabled', 'value' => 'yes' ), array( 'key' => '_pokit_release_ts', 'value' => time(), 'compare' => '<=', 'type' => 'NUMERIC' ), array( 'key' => '_pokit_release_ts', 'value' => 0, 'compare' => '>', 'type' => 'NUMERIC' ), ), ) ); foreach ( $ids as $id ) { $config = pokit_get_config( $id, true ); if ( $config && 'date' === $config['trigger'] && (int) $config['target_id'] === (int) $id ) { self::release_target( $id, array( 'end_preorder' => true ) ); } } } /** * Release active pre-orders of a target in queue order. * * @param int $target_id Target. * @param array $args units (int|null), end_preorder (bool), message, notify, variation_id. * @return int Records released. */ public static function release_target( $target_id, $args = array() ) { $args = wp_parse_args( $args, array( 'units' => null, 'end_preorder' => true, 'message' => '', 'notify' => true, 'variation_id' => '', ) ); $query = array( 'target_id' => $target_id, 'status' => 'active', 'orderby' => 'id', 'order' => 'ASC', ); if ( $args['variation_id'] ) { $query['variation_id'] = $args['variation_id']; } $records = POKIT_Records::query( $query ); $batch = array(); $left = $args['units']; foreach ( $records as $r ) { if ( null !== $left ) { if ( (int) $r->qty > $left ) { break; // Strict queue: never let a later order jump ahead. } $left -= (int) $r->qty; } $batch[] = $r; } $n = self::release_records( $batch, $args['message'], $args['notify'] ); $still_active = POKIT_Records::query( array_merge( $query, array( 'count' => true ) ) ); // Never end a pre-order while customers are still queued (partial shipments, unpaid orders). if ( $args['end_preorder'] && ! $still_active ) { self::end_preorder( $target_id ); } do_action( 'pokit_target_released', $target_id, $n ); return $n; } /** * Release a list of records and send grouped emails. * * @param object[] $records Records. * @param string $message Optional customer message. * @param bool $notify Send emails. * @return int */ public static function release_records( $records, $message = '', $notify = true ) { $done = array(); foreach ( $records as $r ) { if ( is_numeric( $r ) ) { $r = POKIT_Records::get( $r ); } if ( ! $r || ! in_array( $r->status, array( 'active', 'cancel_requested' ), true ) ) { continue; } if ( self::release_record( $r ) ) { $done[ $r->order_id ][] = (int) $r->id; } } $count = 0; foreach ( $done as $order_id => $ids ) { $count += count( $ids ); if ( $notify ) { do_action( 'pokit_email_customer_available', $ids, $message, false ); } $order = wc_get_order( $order_id ); if ( $order ) { self::maybe_complete_parent( $order ); } } return $count; } /** * Release one record: complete it, or create its balance order. * * @param object $r Record. * @return bool */ private static function release_record( $r ) { $order = wc_get_order( $r->order_id ); if ( ! $order || ! $order->get_item( $r->order_item_id ) ) { POKIT_Records::update( $r->id, array( 'status' => 'cancelled' ) ); if ( $order ) { $order->add_order_note( __( 'A pre-order line was removed from this order, so its pre-order record was closed.', 'shipclock-pre-orders' ) ); } return false; } // Orders still waiting for their first payment (e.g. bank transfer on hold) stay in the queue. if ( ! $order->is_paid() ) { return false; } // Claim the record atomically so overlapping runs (sweep, schedule, admin) can't release it twice. $previous = $r->status; if ( ! POKIT_Records::claim( $r->id, array( 'active', 'cancel_requested' ) ) ) { return false; } if ( (float) $r->balance_due > 0.009 ) { /** * Create the order that collects the remaining balance. * * @param WC_Order|WP_Error|null $balance Balance order. * @param object $r Record. * @param WC_Order $order Parent order. */ $balance = apply_filters( 'pokit_create_balance_order', null, $r, $order ); if ( ! $balance instanceof WC_Order ) { $error = is_wp_error( $balance ) ? $balance->get_error_message() : __( 'no balance handler is active.', 'shipclock-pre-orders' ); POKIT_Records::update( $r->id, array( 'status' => $previous ) ); /* translators: %s: error */ $order->add_order_note( sprintf( __( 'Shipclock could not create the balance order: %s', 'shipclock-pre-orders' ), $error ) ); pokit_log( 'Balance order failed for record ' . $r->id . ': ' . $error, 'error' ); return false; } POKIT_Records::update( $r->id, array( 'status' => 'awaiting_payment', 'balance_order_id' => $balance->get_id(), 'released_at' => pokit_now(), ) ); do_action( 'pokit_balance_order_created', $balance, $r, $order ); } else { POKIT_Records::update( $r->id, array( 'status' => 'completed', 'released_at' => pokit_now(), ) ); } do_action( 'pokit_record_released', $r->id ); return true; } /** * When nothing is left open on an order, move it on for fulfilment. * * @param WC_Order $order Order. */ public static function maybe_complete_parent( $order ) { if ( POKIT_Orders::open_records( $order->get_id() ) ) { return; } $completed = POKIT_Records::query( array( 'order_id' => $order->get_id(), 'status' => 'completed', 'count' => true ) ); if ( ! $completed ) { return; } $order->update_meta_data( '_pokit_released', 'yes' ); $order->save(); if ( $order->has_status( 'pokit-preorder' ) ) { POKIT_Orders::set_status( $order, 'processing', __( 'All pre-orders released and paid. Ready to ship.', 'shipclock-pre-orders' ) ); } else { $order->add_order_note( __( 'All pre-order items released and paid. Ready to ship.', 'shipclock-pre-orders' ) ); } } /** * Switch pre-orders off for a target (it becomes a regular product). * * @param int $id Target. */ public static function end_preorder( $id ) { $obj = wc_get_product( $id ); if ( ! $obj ) { return; } if ( 'yes' === $obj->get_meta( '_pokit_enabled' ) ) { $obj->update_meta_data( '_pokit_enabled', 'no' ); } $obj->delete_meta_data( '_pokit_auto' ); $obj->save(); pokit_sync_stock_settings( $obj ); if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( 'pokit_release_target', array( (int) $id ), self::GROUP ); } POKIT_Product_Admin::sync_parent_flag( $obj->is_type( 'variation' ) ? $obj->get_parent_id() : $obj->get_id() ); pokit_flush_config_cache(); } /** * Cancel records. * * @param int[] $ids Record IDs. * @param array $args message, notify (bool), refund (null = setting), by (admin|customer|system). * @return int Cancelled count. */ public static function cancel_records( $ids, $args = array() ) { $args = wp_parse_args( $args, array( 'message' => '', 'notify' => true, 'refund' => null, 'by' => 'admin', ) ); if ( null === $args['refund'] ) { $args['refund'] = 'auto' === pokit_opt( 'refund_on_cancel' ); } if ( $args['refund'] && ! has_filter( 'pokit_refund_record' ) ) { $args['refund'] = false; } $by_order = array(); $was_suspended = self::$suspend_stock; self::$suspend_stock = true; foreach ( (array) $ids as $id ) { $r = POKIT_Records::get( $id ); if ( ! $r || ! in_array( $r->status, pokit_open_statuses(), true ) ) { continue; } // Close the record before any restock/refund so stock changes can't release it or others. POKIT_Records::update( $r->id, array( 'status' => 'cancelled' ) ); $order = wc_get_order( $r->order_id ); // Close an unpaid balance order. if ( $r->balance_order_id ) { $bal = wc_get_order( $r->balance_order_id ); if ( $bal && ! $bal->is_paid() && ! $bal->has_status( 'cancelled' ) ) { POKIT_Orders::set_status( $bal, 'cancelled', __( 'Pre-order cancelled.', 'shipclock-pre-orders' ) ); } } $restocked = false; if ( $order && $args['refund'] && (float) $r->amount_paid > 0 && $order->is_paid() && has_filter( 'pokit_refund_record' ) ) { /** * Refund what was paid for a cancelled pre-order. Return true if stock was restocked. * * @param bool $restocked Restocked. * @param object $r Record. * @param WC_Order $order Order. */ $restocked = (bool) apply_filters( 'pokit_refund_record', false, $r, $order ); } elseif ( $order && (float) $r->amount_paid > 0 && $order->is_paid() ) { /* translators: %s: amount */ $order->add_order_note( sprintf( __( 'Pre-order cancelled. %s had been paid for it – refund it manually if needed.', 'shipclock-pre-orders' ), wp_strip_all_tags( wc_price( $r->amount_paid, array( 'currency' => $order->get_currency() ) ) ) ) ); } if ( $order && ! $restocked ) { $item = $order->get_item( $r->order_item_id ); $product = $item instanceof WC_Order_Item_Product ? $item->get_product() : null; if ( $product && $product->managing_stock() && $item->get_meta( '_reduced_stock' ) ) { wc_update_product_stock( $product, (int) $r->qty, 'increase' ); $item->delete_meta_data( '_reduced_stock' ); $item->save(); } } $by_order[ $r->order_id ][] = (int) $r->id; if ( $order ) { $who = array( 'admin' => __( 'by the shop', 'shipclock-pre-orders' ), 'customer' => __( 'by the customer', 'shipclock-pre-orders' ), 'system' => __( 'automatically', 'shipclock-pre-orders' ), ); $order->add_order_note( sprintf( /* translators: 1: product, 2: who */ __( 'Pre-order for "%1$s" cancelled %2$s.', 'shipclock-pre-orders' ), $order->get_item( $r->order_item_id ) ? $order->get_item( $r->order_item_id )->get_name() : '#' . $r->product_id, $who[ $args['by'] ] ?? '' ) ); } } self::$suspend_stock = $was_suspended; foreach ( $by_order as $order_id => $rids ) { if ( $args['notify'] ) { do_action( 'pokit_email_customer_cancelled', $order_id, $rids, $args['message'] ); } if ( 'customer' === $args['by'] ) { do_action( 'pokit_email_admin_cancel', $order_id, $rids, 'cancelled' ); } $order = wc_get_order( $order_id ); if ( $order && pokit_order_is_preorder_only( $order ) && ! POKIT_Orders::open_records( $order_id ) && ! POKIT_Records::query( array( 'order_id' => $order_id, 'status' => 'completed', 'count' => true ) ) && ! $order->has_status( array( 'cancelled', 'refunded' ) ) ) { POKIT_Orders::set_status( $order, 'cancelled', __( 'All pre-orders in this order were cancelled.', 'shipclock-pre-orders' ) ); } elseif ( $order ) { self::maybe_complete_parent( $order ); } } return array_sum( array_map( 'count', $by_order ) ); } /** * Change the release date for a target and its open pre-orders. * * @param int $target_id Target. * @param int $ts New timestamp (0 = no date). * @param bool $notify Email customers. * @param string $message Message. * @return int Records updated. */ public static function change_date( $target_id, $ts, $notify = true, $message = '' ) { $obj = wc_get_product( $target_id ); if ( $obj && (int) $obj->get_meta( '_pokit_release_ts' ) !== (int) $ts ) { $obj->update_meta_data( '_pokit_release_ts', $ts ? (int) $ts : '' ); $obj->save(); } pokit_flush_config_cache(); $records = POKIT_Records::query( array( 'target_id' => $target_id, 'status' => pokit_open_statuses() ) ); $by_order = array(); foreach ( $records as $r ) { POKIT_Records::update( $r->id, array( 'release_ts' => (int) $ts ) ); $order = wc_get_order( $r->order_id ); if ( $order ) { $item = $order->get_item( $r->order_item_id ); if ( $item ) { $item->update_meta_data( '_pokit_release_ts', (int) $ts ); $item->update_meta_data( __( 'Pre-order release', 'shipclock-pre-orders' ), pokit_format_release( $ts ) ); $item->save(); } } $by_order[ $r->order_id ][] = (int) $r->id; } self::schedule_target( $target_id ); if ( $notify ) { foreach ( $by_order as $order_id => $ids ) { do_action( 'pokit_email_customer_date_changed', $order_id, $ids, $message ); } } return count( $records ); } }