__( 'Product discount', 'digidiscount' ), 'category' => __( 'Category discount', 'digidiscount' ), 'cart' => __( 'Cart discount', 'digidiscount' ), 'threshold' => __( 'Spend X, save Y', 'digidiscount' ), ) ); } public static function schema(): array { $schema = array( 'name' => array( 'label' => __( 'Campaign name', 'digidiscount' ), 'help' => __( 'Shown to managers and customers. Use a short, clear name.', 'digidiscount' ), 'type' => 'text', 'default' => '', 'group' => 'basics' ), 'enabled' => array( 'label' => __( 'Enable campaign', 'digidiscount' ), 'help' => __( 'Turn this campaign on or pause it without deleting its settings.', 'digidiscount' ), 'type' => 'checkbox', 'default' => false, 'group' => 'basics' ), 'stackable' => array( 'label' => __( 'Allow stacking', 'digidiscount' ), 'help' => __( 'Allow this campaign to combine with others when the global combine mode is selected.', 'digidiscount' ), 'type' => 'checkbox', 'default' => false, 'group' => 'advanced' ), 'group' => array( 'label' => __( 'Exclusive group', 'digidiscount' ), 'help' => __( 'Only the first matching campaign in the same named group can apply in combine mode.', 'digidiscount' ), 'type' => 'text', 'default' => '', 'group' => 'advanced' ), 'priority' => array( 'label' => __( 'Priority', 'digidiscount' ), 'help' => __( 'Lower numbers run first. Ties use the campaign ID.', 'digidiscount' ), 'type' => 'number', 'default' => 10, 'group' => 'basics' ), 'products' => array( 'label' => __( 'Include products or variations', 'digidiscount' ), 'help' => __( 'Search products. Empty means all products. A parent product includes its variations.', 'digidiscount' ), 'type' => 'lookup', 'source' => 'products', 'default' => array(), 'group' => 'audience' ), 'categories' => array( 'label' => __( 'Include categories', 'digidiscount' ), 'help' => __( 'Products must match a selected category. Child categories are included.', 'digidiscount' ), 'type' => 'lookup', 'source' => 'categories', 'default' => array(), 'group' => 'audience' ), 'exclude_products' => array( 'label' => __( 'Exclude products', 'digidiscount' ), 'help' => __( 'These products never receive this campaign discount.', 'digidiscount' ), 'type' => 'lookup', 'source' => 'products', 'default' => array(), 'group' => 'audience' ), 'exclude_categories' => array( 'label' => __( 'Exclude categories', 'digidiscount' ), 'help' => __( 'Exclude these categories and their child categories.', 'digidiscount' ), 'type' => 'lookup', 'source' => 'categories', 'default' => array(), 'group' => 'audience' ), 'exclude_sale' => array( 'label' => __( 'Exclude sale items', 'digidiscount' ), 'help' => __( 'Skip products with an active WooCommerce sale price.', 'digidiscount' ), 'type' => 'checkbox', 'default' => true, 'group' => 'audience' ), 'kind' => array( 'label' => __( 'Discount method', 'digidiscount' ), 'help' => __( 'Fixed total is shared across eligible items. Per item reduces every eligible unit.', 'digidiscount' ), 'type' => 'select', 'options' => array( 'percent' => __( 'Percentage', 'digidiscount' ), 'fixed' => __( 'Fixed total', 'digidiscount' ), 'unit' => __( 'Fixed per item', 'digidiscount' ) ), 'default' => 'percent', 'group' => 'discount' ), 'amount' => array( 'label' => __( 'Discount value', 'digidiscount' ), 'help' => __( 'Enter a percentage from 0 to 100, or an amount in the campaign currency.', 'digidiscount' ), 'type' => 'number', 'default' => 10, 'group' => 'discount' ), 'max_discount' => array( 'label' => __( 'Maximum discount per order', 'digidiscount' ), 'help' => __( 'Cap this campaign in the campaign currency. Zero means no extra cap.', 'digidiscount' ), 'type' => 'number', 'default' => 0, 'group' => 'discount' ), 'min_total' => array( 'label' => __( 'Minimum purchase amount', 'digidiscount' ), 'help' => __( 'Before coupons and shipping, using the tax basis of your entered product prices.', 'digidiscount' ), 'type' => 'number', 'default' => 0, 'group' => 'conditions' ), 'min_qty' => array( 'label' => __( 'Minimum quantity', 'digidiscount' ), 'help' => __( 'Minimum total quantity in the selected threshold scope.', 'digidiscount' ), 'type' => 'number', 'default' => 0, 'group' => 'conditions' ), 'max_qty' => array( 'label' => __( 'Maximum quantity', 'digidiscount' ), 'help' => __( 'Zero means no maximum quantity.', 'digidiscount' ), 'type' => 'number', 'default' => 0, 'group' => 'conditions' ), 'threshold_scope' => array( 'label' => __( 'Threshold scope', 'digidiscount' ), 'help' => __( 'Choose whether qualifying amounts and quantities use eligible products or all paid cart items.', 'digidiscount' ), 'type' => 'select', 'options' => array( 'eligible' => __( 'Eligible items', 'digidiscount' ), 'cart' => __( 'Entire cart', 'digidiscount' ) ), 'default' => 'eligible', 'group' => 'conditions' ), 'allow_coupons' => array( 'label' => __( 'Allow other coupons', 'digidiscount' ), 'help' => __( 'When off, this campaign pauses while a customer coupon is applied. Individual-use coupons always take precedence.', 'digidiscount' ), 'type' => 'checkbox', 'default' => false, 'group' => 'conditions' ), 'start' => array( 'label' => __( 'Starts at', 'digidiscount' ), 'help' => __( 'Store timezone. Empty means immediately. The start time is inclusive.', 'digidiscount' ), 'type' => 'datetime-local', 'default' => '', 'group' => 'schedule' ), 'end' => array( 'label' => __( 'Ends at', 'digidiscount' ), 'help' => __( 'Store timezone. Empty means no expiry. The end time is exclusive.', 'digidiscount' ), 'type' => 'datetime-local', 'default' => '', 'group' => 'schedule' ), 'show_archive' => array( 'label' => __( 'Show campaign prices in product lists', 'digidiscount' ), 'help' => __( 'Show the original price, campaign price and savings in shop, category and related-product cards. Conditional offers include their requirements.', 'digidiscount' ), 'type' => 'checkbox', 'default' => false, 'group' => 'display' ), 'show_single' => array( 'label' => __( 'Show campaign prices on product pages', 'digidiscount' ), 'help' => __( 'Show campaign pricing on the product detail page and selected variations. Cart-wide offers show their conditions when a product price cannot be calculated.', 'digidiscount' ), 'type' => 'checkbox', 'default' => false, 'group' => 'display' ), 'message' => array( 'label' => __( 'Product page message', 'digidiscount' ), 'help' => __( 'Optional campaign description. It advertises conditions and is not a guaranteed price.', 'digidiscount' ), 'type' => 'text', 'default' => '', 'group' => 'display' ), ); return apply_filters( 'digidiscount_rule_schema', $schema ); } public static function defaults(): array { return array_merge( array( 'id' => '', 'type' => 'product', 'currency' => get_woocommerce_currency() ), array_map( static fn( $field ) => $field['default'], self::schema() ) ); } public static function sanitize( array $input ) { $type = sanitize_key( $input['type'] ?? 'product' ); if ( ! isset( self::types()[ $type ] ) ) { return new \WP_Error( 'digidiscount_type', __( 'This campaign type is not registered by an installed extension.', 'digidiscount' ), array( 'status' => 400 ) ); } if ( ! self::supported( $input ) ) { return new \WP_Error( 'digidiscount_conditions', __( 'The extension that defines these campaign conditions is unavailable. The saved campaign has been preserved.', 'digidiscount' ), array( 'status' => 409 ) ); } $out = array( 'type' => $type, 'currency' => get_woocommerce_currency() ); foreach ( self::schema() as $key => $field ) { $value = $input[ $key ] ?? $field['default']; switch ( $field['type'] ) { case 'checkbox': $value = rest_sanitize_boolean( $value ); break; case 'number': $value = min( 1000000000, max( 0, (float) wc_format_decimal( is_scalar( $value ) ? $value : 0 ) ) ); break; case 'lookup': $value = array_slice( array_values( array_unique( array_filter( array_map( 'absint', (array) $value ) ) ) ), 0, 500 ); break; case 'multiselect': $items = is_array( $value ) ? $value : explode( ',', is_scalar( $value ) ? (string) $value : '' ); $value = implode( ',', array_slice( array_values( array_unique( array_filter( array_map( static fn( $item ) => is_scalar( $item ) ? sanitize_key( trim( (string) $item ) ) : '', $items ) ) ) ), 0, 100 ) ); break; case 'select': $value = is_scalar( $value ) && isset( $field['options'][ $value ] ) ? (string) $value : $field['default']; break; case 'datetime-local': if ( $value ) { $date = \DateTimeImmutable::createFromFormat( '!Y-m-d\TH:i', (string) $value, wp_timezone() ); if ( ! $date || $date->format( 'Y-m-d\TH:i' ) !== $value ) { return new \WP_Error( 'digidiscount_date', __( 'Enter a valid date and time.', 'digidiscount' ), array( 'status' => 400 ) ); } } $value = sanitize_text_field( is_scalar( $value ) ? $value : '' ); break; case 'textarea': $value = substr( sanitize_textarea_field( is_scalar( $value ) ? $value : '' ), 0, 10000 ); break; default: $value = substr( sanitize_text_field( is_scalar( $value ) ? $value : '' ), 0, 500 ); } $out[ $key ] = $value; } if ( ! $out['name'] ) { return new \WP_Error( 'digidiscount_name', __( 'Give this campaign a name.', 'digidiscount' ), array( 'status' => 400 ) ); } if ( 'percent' === $out['kind'] && $out['amount'] > 100 ) { return new \WP_Error( 'digidiscount_amount', __( 'Percentage cannot exceed 100.', 'digidiscount' ), array( 'status' => 400 ) ); } if ( ( $out['start'] && $out['end'] && $out['end'] <= $out['start'] ) || ( $out['max_qty'] && $out['max_qty'] < $out['min_qty'] ) ) { return new \WP_Error( 'digidiscount_range', __( 'The end of a range must be greater than its start.', 'digidiscount' ), array( 'status' => 400 ) ); } return apply_filters( 'digidiscount_validate_rule', $out, $input ); } public static function supported( array $rule ): bool { if ( ! isset( self::types()[ $rule['type'] ?? 'product' ] ) ) { return false; } $known = array_merge( array_keys( self::schema() ), array( 'id', 'type', 'currency', 'requires_pro', 'available' ) ); foreach ( array_diff_key( $rule, array_flip( $known ) ) as $value ) { if ( ! empty( $value ) ) { return false; } } return true; } public static function all(): array { $posts = get_posts( array( 'post_type' => Storage::post_types(), 'post_status' => 'publish', 'numberposts' => -1, 'orderby' => 'ID', 'order' => 'DESC' ) ); return array_values( array_filter( array_map( static function ( $post ) { $rule = Storage::rule( $post->ID ); if ( ! $rule ) { return null; } $rule['id'] = (string) $post->ID; return $rule; }, $posts ) ) ); } public static function runtime(): array { $factor = 10 ** wc_get_price_decimals(); $rules = array(); foreach ( self::all() as $rule ) { if ( ! self::supported( $rule ) ) { continue; } foreach ( array( 'min_total', 'max_discount' ) as $key ) { $rule[ $key ] = (int) round( $rule[ $key ] * $factor ); } if ( 'percent' !== $rule['kind'] ) { $rule['amount'] = (int) round( $rule['amount'] * $factor ); } foreach ( array( 'start', 'end' ) as $key ) { $rule[ $key ] = $rule[ $key ] ? ( new \DateTimeImmutable( $rule[ $key ], wp_timezone() ) )->getTimestamp() : 0; } $rules[] = apply_filters( 'digidiscount_runtime_rule', $rule, $factor ); } return $rules; } }