'upfront', // upfront|release|deposit|choice. 'deposit_type' => 'percent', // percent|fixed. 'deposit_amount' => '20', 'choice_options' => array( 'upfront', 'release', 'deposit' ), 'cart_policy' => 'mixed', // mixed|preorders_only|single. 'auto_out_of_stock' => 'no', 'customer_cancel' => 'allow', // allow|request|none. 'refund_on_cancel' => 'manual', // auto|manual. 'reminder_days' => '3', 'expire_days' => '14', 'disable_processing' => 'no', // Display. 'button_text' => __( 'Pre-order now', 'shipclock-pre-orders' ), 'place_order_text' => __( 'Place pre-order', 'shipclock-pre-orders' ), 'product_message' => __( 'Available for pre-order. Expected release: {release_date}.', 'shipclock-pre-orders' ), 'product_message_nodate' => __( 'Available for pre-order. We will email you as soon as it ships.', 'shipclock-pre-orders' ), 'loop_message' => __( 'Pre-order ยท {release_date}', 'shipclock-pre-orders' ), 'nodate_label' => __( 'Coming soon', 'shipclock-pre-orders' ), 'show_badge' => 'yes', 'badge_text' => __( 'Pre-order', 'shipclock-pre-orders' ), 'show_remaining' => 'yes', 'remaining_text' => __( 'Only {remaining} pre-order spots left', 'shipclock-pre-orders' ), 'soldout_text' => __( 'Pre-orders are sold out', 'shipclock-pre-orders' ), 'show_countdown' => 'yes', ); /** * Filter default settings. * * @param array $defaults Defaults. */ return apply_filters( 'pokit_default_settings', $defaults ); } /** * All settings, merged with defaults. * * @return array */ function pokit_settings() { static $cache = null; if ( null === $cache || doing_action( 'update_option_pokit_settings' ) ) { $saved = get_option( 'pokit_settings', array() ); $cache = wp_parse_args( is_array( $saved ) ? $saved : array(), pokit_default_settings() ); } return $cache; } /** * Single setting. * * @param string $key Key. * @return mixed */ function pokit_opt( $key ) { $s = pokit_settings(); return isset( $s[ $key ] ) ? $s[ $key ] : null; } /** * Charge modes. * * @return array */ function pokit_modes() { return array( 'upfront' => __( 'Pay in full now', 'shipclock-pre-orders' ), 'release' => __( 'Pay on release', 'shipclock-pre-orders' ), 'deposit' => __( 'Pay a deposit now, balance on release', 'shipclock-pre-orders' ), 'choice' => __( 'Let the customer choose', 'shipclock-pre-orders' ), ); } /** * Short label shown to customers for a chosen mode. * * @param string $mode Mode. * @return string */ function pokit_mode_label( $mode ) { $labels = array( 'upfront' => __( 'Paid in full', 'shipclock-pre-orders' ), 'release' => __( 'Pay on release', 'shipclock-pre-orders' ), 'deposit' => __( 'Deposit now, balance on release', 'shipclock-pre-orders' ), ); return isset( $labels[ $mode ] ) ? $labels[ $mode ] : $mode; } /** * Record status labels. * * @return array */ function pokit_record_statuses() { return array( 'active' => __( 'Active', 'shipclock-pre-orders' ), 'awaiting_payment' => __( 'Awaiting balance', 'shipclock-pre-orders' ), 'cancel_requested' => __( 'Cancellation requested', 'shipclock-pre-orders' ), 'releasing' => __( 'Releasing', 'shipclock-pre-orders' ), 'completed' => __( 'Released', 'shipclock-pre-orders' ), 'cancelled' => __( 'Cancelled', 'shipclock-pre-orders' ), ); } /** * Statuses that still "hold" a pre-order unit. * * @return string[] */ function pokit_open_statuses() { return array( 'active', 'releasing', 'awaiting_payment', 'cancel_requested' ); } /** * Get a product from an ID or object. * * @param mixed $product Product or ID. * @return WC_Product|null */ function pokit_product( $product ) { if ( $product instanceof WC_Product ) { return $product; } $p = $product ? wc_get_product( $product ) : null; return $p ? $p : null; } /** * Resolve the effective pre-order configuration of a product or variation. * * Returns null when the product is not currently a pre-order. * * Inheritance: variation โ†’ parent product โ†’ global settings. * * @param mixed $product Product, variation or ID. * @param bool $ignore_release Return config even if the release date has passed. * @return array|null */ function pokit_get_config( $product, $ignore_release = false ) { global $pokit_config_cache; if ( ! is_array( $pokit_config_cache ) ) { $pokit_config_cache = array(); } $cache = &$pokit_config_cache; $product = pokit_product( $product ); if ( ! $product ) { return null; } $key = $product->get_id() . ( $ignore_release ? ':r' : '' ); if ( array_key_exists( $key, $cache ) ) { return $cache[ $key ]; } $parent = null; $source = null; if ( $product->is_type( 'variation' ) ) { $parent = wc_get_product( $product->get_parent_id() ); if ( $parent && 'yes' === $parent->get_meta( '_pokit_enabled' ) ) { $source = $parent; } } elseif ( 'yes' === $product->get_meta( '_pokit_enabled' ) ) { $source = $product; } /** * Filter which product holds the pre-order settings (e.g. a variation with its own settings). * * @param WC_Product|null $source Source product or null when not a pre-order. * @param WC_Product $product Product or variation. * @param WC_Product|null $parent Parent of a variation. */ $source = apply_filters( 'pokit_config_source', $source, $product, $parent ); if ( ! $source instanceof WC_Product ) { $cache[ $key ] = null; return null; } // Read a setting from the source, falling back to the parent product. $get = static function ( $meta ) use ( $source, $parent ) { $v = $source->get_meta( $meta ); if ( ( '' === $v || null === $v ) && $parent && $source->get_id() !== $parent->get_id() ) { $v = $parent->get_meta( $meta ); } return $v; }; $trigger = $get( '_pokit_release_trigger' ); if ( ! in_array( $trigger, array( 'date', 'manual' ), true ) ) { $trigger = 'stock' === $trigger ? 'manual' : 'date'; } $config = array( 'product_id' => $product->get_id(), 'root_id' => $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(), 'target_id' => $source->get_id(), 'mode' => 'upfront', 'deposit_type' => 'percent', 'deposit_amount' => 0.0, 'trigger' => $trigger, 'release_ts' => (int) $get( '_pokit_release_ts' ), 'adjust_type' => '', 'adjust_amount' => 0.0, 'max_qty' => 0, 'per_order_max' => 0, 'button_text' => (string) $get( '_pokit_button_text' ), 'message' => (string) $get( '_pokit_message' ), 'auto' => 'yes' === $source->get_meta( '_pokit_auto' ), ); /** * Filter the resolved pre-order configuration. * * @param array $config Config. * @param WC_Product $product Product or variation. * @param WC_Product $source Product holding the settings. * @param WC_Product|null $parent Parent of a variation. * @param callable $get Meta reader with parent fallback. */ $config = apply_filters( 'pokit_product_config', $config, $product, $source, $parent, $get ); if ( ! $ignore_release && 'date' === $config['trigger'] && $config['release_ts'] && $config['release_ts'] <= time() ) { $cache[ $key ] = null; return null; } $cache[ $key ] = $config; return $config; } /** * Clear the config cache (after saving products). */ function pokit_flush_config_cache() { global $pokit_config_cache; $pokit_config_cache = array(); do_action( 'pokit_config_changed' ); } /** * Is this product (or variation) currently a pre-order? * * @param mixed $product Product or ID. * @return bool */ function pokit_is_preorder( $product ) { return null !== pokit_get_config( $product ); } /** * Does a variable product have pre-orders on the parent or any variation? * * @param WC_Product $product Product. * @return bool */ function pokit_has_any_preorder( $product ) { $product = pokit_product( $product ); if ( ! $product ) { return false; } if ( pokit_is_preorder( $product ) ) { return true; } if ( $product->is_type( 'variable' ) ) { foreach ( $product->get_children() as $child_id ) { if ( pokit_is_preorder( $child_id ) ) { return true; } } } return false; } /** * Apply the pre-order discount / surcharge to a price. * * @param float $price Base price. * @param array $config Config. * @return float */ function pokit_adjust_price( $price, $config ) { $price = (float) $price; $amount = (float) $config['adjust_amount']; if ( $amount <= 0 ) { return $price; } switch ( $config['adjust_type'] ) { case 'discount_percent': $price -= $price * min( 100, $amount ) / 100; break; case 'discount_fixed': $price -= $amount; break; case 'surcharge_percent': $price += $price * $amount / 100; break; case 'surcharge_fixed': $price += $amount; break; } return max( 0, round( $price, wc_get_price_decimals() ) ); } /** * Deposit amount per unit. * * @param float $unit_full Full unit price. * @param array $config Config. * @return float */ function pokit_deposit_amount( $unit_full, $config ) { $amount = (float) $config['deposit_amount']; if ( 'fixed' === $config['deposit_type'] ) { $deposit = min( $amount, $unit_full ); } else { $deposit = $unit_full * min( 100, max( 0, $amount ) ) / 100; } return round( $deposit, wc_get_price_decimals() ); } /** * Per-unit amount charged at checkout for a mode. * * @param float $unit_full Full unit price. * @param string $mode Mode. * @param array $config Config. * @return float */ function pokit_amount_now( $unit_full, $mode, $config ) { switch ( $mode ) { case 'release': return 0.0; case 'deposit': return pokit_deposit_amount( $unit_full, $config ); default: return (float) $unit_full; } } /** * Modes a customer may pick from for a product. * * @param array $config Config. * @return string[] */ function pokit_available_modes( $config ) { if ( 'choice' !== $config['mode'] ) { return array( $config['mode'] ); } $opts = (array) pokit_opt( 'choice_options' ); $opts = array_values( array_intersect( array( 'upfront', 'release', 'deposit' ), $opts ) ); if ( $config['deposit_amount'] <= 0 ) { $opts = array_values( array_diff( $opts, array( 'deposit' ) ) ); } return $opts ? $opts : array( 'upfront' ); } /** * Human-readable release date (or the "coming soon" label). * * @param int $ts Timestamp. * @param bool $with_time Include time. * @return string */ function pokit_format_release( $ts, $with_time = false ) { if ( ! $ts ) { return (string) pokit_opt( 'nodate_label' ); } $format = get_option( 'date_format' ); if ( $with_time ) { $format .= ' ' . get_option( 'time_format' ); } return wp_date( $format, (int) $ts ); } /** * Units still available under a pre-order cap. -1 = unlimited. * * @param array $config Config. * @return int */ function pokit_remaining( $config ) { if ( empty( $config['max_qty'] ) ) { return -1; } $used = POKIT_Records::units( $config['target_id'], pokit_open_statuses() ) + pokit_units_in_checkout( $config['target_id'] ); return max( 0, (int) $config['max_qty'] - $used ); } /** * Units of a target sitting in orders that are still being paid (pending / checkout draft, last hour), * so concurrent checkouts can't oversell a pre-order limit. The current shopper's own draft is skipped. * * @param int $target_id Target. * @return int */ function pokit_units_in_checkout( $target_id ) { static $cache = array(); if ( isset( $cache[ $target_id ] ) ) { return $cache[ $target_id ]; } $skip = 0; if ( function_exists( 'WC' ) && WC()->session ) { $skip = (int) WC()->session->get( 'store_api_draft_order' ); $skip = $skip ? $skip : (int) WC()->session->get( 'order_awaiting_payment' ); } $ids = wc_get_orders( array( 'status' => array( 'pending', 'checkout-draft' ), 'date_created' => '>' . ( time() - HOUR_IN_SECONDS ), 'limit' => 200, 'return' => 'ids', ) ); $units = 0; foreach ( $ids as $oid ) { if ( (int) $oid === $skip ) { continue; } $order = wc_get_order( $oid ); if ( ! $order || 'yes' === $order->get_meta( '_pokit_records_created' ) ) { continue; } foreach ( pokit_order_preorder_items( $order ) as $item ) { if ( (int) $item->get_meta( '_pokit_target' ) === (int) $target_id ) { $units += (int) $item->get_quantity(); } } } $cache[ $target_id ] = $units; return $units; } /** * Replace message placeholders. * * @param string $text Text. * @param array $config Config. * @param WC_Product $product Product. * @return string */ function pokit_placeholders( $text, $config, $product = null ) { $remaining = pokit_remaining( $config ); $repl = array( '{release_date}' => pokit_format_release( $config['release_ts'] ), '{release_time}' => $config['release_ts'] ? wp_date( get_option( 'time_format' ), $config['release_ts'] ) : '', // Backwards compatible with the official extension's placeholders. '{availability_date}' => pokit_format_release( $config['release_ts'] ), '{availability_time}' => $config['release_ts'] ? wp_date( get_option( 'time_format' ), $config['release_ts'] ) : '', '{remaining}' => $remaining < 0 ? '' : (string) $remaining, '{product}' => $product ? $product->get_name() : '', ); return strtr( $text, $repl ); } /** * Pre-order line items in an order. * * @param WC_Order $order Order. * @return WC_Order_Item_Product[] */ function pokit_order_preorder_items( $order ) { $items = array(); if ( ! $order instanceof WC_Order ) { return $items; } foreach ( $order->get_items() as $item_id => $item ) { if ( $item->get_meta( '_pokit_mode' ) ) { $items[ $item_id ] = $item; } } return $items; } /** * Does the order contain only pre-order items? * * @param WC_Order $order Order. * @return bool */ function pokit_order_is_preorder_only( $order ) { $pre = pokit_order_preorder_items( $order ); return $pre && count( $pre ) === count( $order->get_items() ); } /** * Is this order a balance-payment order created by Shipclock? * * @param WC_Order|int $order Order. * @return bool */ function pokit_is_balance_order( $order ) { $order = $order instanceof WC_Order ? $order : wc_get_order( $order ); return $order && 'yes' === $order->get_meta( '_pokit_balance_order' ); } /** * Current time as MySQL datetime (UTC). * * @return string */ function pokit_now() { return gmdate( 'Y-m-d H:i:s' ); } /** * Convert a datetime-local string (site timezone) to a timestamp. * * @param string $value Value like 2026-12-01T10:00. * @return int */ function pokit_local_to_ts( $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return 0; } try { $dt = new DateTime( $value, wp_timezone() ); return $dt->getTimestamp(); } catch ( Exception $e ) { return 0; } } /** * Timestamp โ†’ datetime-local string in site timezone. * * @param int $ts Timestamp. * @return string */ function pokit_ts_to_local( $ts ) { return $ts ? wp_date( 'Y-m-d\TH:i', (int) $ts ) : ''; } /** * Make stock settings match pre-order state. * * While a stock-managed product is on pre-order we switch backorders on, so WooCommerce keeps it * visible ("on backorder" instead of "out of stock", even with "Hide out of stock items") and stock * can go negative (= units owed). The merchant's own backorder setting is restored afterwards. * * @param WC_Product $product Product, variation or variable parent. */ function pokit_sync_stock_settings( $product ) { $product = pokit_product( $product ); if ( ! $product ) { return; } $items = $product->is_type( 'variable' ) ? array_filter( array_map( 'wc_get_product', $product->get_children() ) ) : array( $product ); foreach ( $items as $p ) { $on = null !== pokit_get_config( $p, true ); $changed = false; if ( $on ) { if ( true === $p->get_manage_stock() && 'no' === $p->get_backorders() ) { $p->update_meta_data( '_pokit_prev_backorders', 'no' ); $p->set_backorders( 'yes' ); $changed = true; } elseif ( ! $p->get_manage_stock() && 'outofstock' === $p->get_stock_status() ) { $p->set_stock_status( 'instock' ); $changed = true; } } elseif ( $p->get_meta( '_pokit_prev_backorders' ) ) { $p->set_backorders( $p->get_meta( '_pokit_prev_backorders' ) ); $p->delete_meta_data( '_pokit_prev_backorders' ); $changed = true; } if ( $changed ) { $p->save(); } } if ( $product->is_type( 'variation' ) ) { WC_Product_Variable::sync( $product->get_parent_id() ); } pokit_flush_config_cache(); } /** * Log helper. * * @param string $message Message. * @param string $level Level. */ function pokit_log( $message, $level = 'info' ) { if ( function_exists( 'wc_get_logger' ) ) { wc_get_logger()->log( $level, $message, array( 'source' => 'shipclock-pre-orders' ) ); } }