| 1 |
<?php |
| 2 |
|
| 3 |
namespace StoreEngine\API; |
| 4 |
|
| 5 |
use stdClass; |
| 6 |
use StoreEngine\Classes\Exceptions\StoreEngineException; |
| 7 |
use StoreEngine\Utils\Formatting; |
| 8 |
use StoreEngine\Utils\Helper; |
| 9 |
use WP_Error; |
| 10 |
use WP_REST_Request; |
| 11 |
|
| 12 |
if ( ! defined( 'ABSPATH' ) ) { |
| 13 |
exit; |
| 14 |
} |
| 15 |
|
| 16 |
class Coupon { |
| 17 |
|
| 18 |
/** |
| 19 |
* Derived, queryable copy of the coupon end date/time as a single unix |
| 20 |
* timestamp. The authoritative value (`_storeengine_coupon_end_date_time`) |
| 21 |
* is a serialized `{date, time, timezone}` object, which cannot be compared |
| 22 |
* in a meta_query — so we mirror it into this scalar on every save and |
| 23 |
* filter the admin list against it. `0` means "no end date" (never expires). |
| 24 |
*/ |
| 25 |
const END_TS_META = '_storeengine_coupon_end_ts'; |
| 26 |
|
| 27 |
/** |
| 28 |
* Meta storing which time mode a coupon uses. `set_time_limit` means it has a |
| 29 |
* start/end window; anything else (default `forever_time`, or unset on legacy |
| 30 |
* coupons) means it is open-ended. |
| 31 |
*/ |
| 32 |
const TIME_TYPE_META = '_storeengine_coupon_time_type'; |
| 33 |
|
| 34 |
public static function init() { |
| 35 |
$self = new self(); |
| 36 |
add_filter( 'rest_pre_insert_storeengine_coupon', [ $self, 'validate_request_data' ], 10, 2 ); |
| 37 |
|
| 38 |
// Keep the queryable end-timestamp mirror in sync on create/update. |
| 39 |
add_action( 'rest_after_insert_' . Helper::COUPON_POST_TYPE, [ $self, 'sync_end_timestamp' ], 10, 1 ); |
| 40 |
|
| 41 |
// Expose the `se_expiry` filter on the coupon REST collection and turn it |
| 42 |
// into a meta_query against the mirrored timestamp. |
| 43 |
add_filter( 'rest_' . Helper::COUPON_POST_TYPE . '_collection_params', [ $self, 'register_expiry_param' ] ); |
| 44 |
add_filter( 'rest_' . Helper::COUPON_POST_TYPE . '_query', [ $self, 'filter_by_expiry' ], 10, 2 ); |
| 45 |
} |
| 46 |
|
| 47 |
/** |
| 48 |
* Reduce the serialized end date/time meta to a single unix timestamp. |
| 49 |
* |
| 50 |
* Uses the same `strtotime( "date time" )` reading as the coupon-expiry |
| 51 |
* validator (see Coupon::check_coupon_between_start_and_end_date) so the |
| 52 |
* "expired" list filter and the real checkout expiry agree. Returns `0` |
| 53 |
* when no end date is set, which is the sentinel for "never expires". |
| 54 |
* |
| 55 |
* @param mixed $end_date_time The `_storeengine_coupon_end_date_time` meta value. |
| 56 |
*/ |
| 57 |
public static function compute_end_timestamp( $end_date_time ): int { |
| 58 |
if ( ! is_array( $end_date_time ) || empty( $end_date_time['date'] ) ) { |
| 59 |
return 0; |
| 60 |
} |
| 61 |
|
| 62 |
$time = $end_date_time['time'] ?? ''; |
| 63 |
$ts = strtotime( trim( $end_date_time['date'] . ' ' . $time ) ); |
| 64 |
|
| 65 |
return $ts ? (int) $ts : 0; |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* Mirror the coupon end date/time into the queryable timestamp meta. |
| 70 |
* |
| 71 |
* @param \WP_Post $post The saved coupon post. |
| 72 |
*/ |
| 73 |
public function sync_end_timestamp( $post ) { |
| 74 |
if ( ! $post || Helper::COUPON_POST_TYPE !== $post->post_type ) { |
| 75 |
return; |
| 76 |
} |
| 77 |
|
| 78 |
$end_date_time = get_post_meta( $post->ID, '_storeengine_coupon_end_date_time', true ); |
| 79 |
update_post_meta( $post->ID, self::END_TS_META, self::compute_end_timestamp( $end_date_time ) ); |
| 80 |
} |
| 81 |
|
| 82 |
/** |
| 83 |
* Register the `se_expiry` query param on the coupon REST collection. |
| 84 |
* |
| 85 |
* @param array $params Collection params. |
| 86 |
* @return array |
| 87 |
*/ |
| 88 |
public function register_expiry_param( array $params ): array { |
| 89 |
$params['se_expiry'] = [ |
| 90 |
'description' => __( 'Limit coupons by expiry state: "expired" (past end date), "no_end" (time limit set but no end date) or "forever" (open-ended, no time limit).', 'storeengine' ), |
| 91 |
'type' => 'string', |
| 92 |
'enum' => [ 'expired', 'no_end', 'forever' ], |
| 93 |
]; |
| 94 |
|
| 95 |
return $params; |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* Translate the `se_expiry` param into a meta_query on the mirrored timestamp. |
| 100 |
* |
| 101 |
* @param array $args WP_Query args. |
| 102 |
* @param WP_REST_Request $request REST request. |
| 103 |
* @return array |
| 104 |
*/ |
| 105 |
public function filter_by_expiry( array $args, WP_REST_Request $request ): array { |
| 106 |
$expiry = $request->get_param( 'se_expiry' ); |
| 107 |
if ( ! $expiry ) { |
| 108 |
return $args; |
| 109 |
} |
| 110 |
|
| 111 |
$meta_query = $args['meta_query'] ?? []; |
| 112 |
|
| 113 |
if ( 'expired' === $expiry ) { |
| 114 |
// A set, in-the-past end date. Bounded below by 1 so "no end date" |
| 115 |
// (stored as 0) is never counted as expired. |
| 116 |
$meta_query[] = [ |
| 117 |
'key' => self::END_TS_META, |
| 118 |
'value' => [ 1, time() ], |
| 119 |
'compare' => 'BETWEEN', |
| 120 |
'type' => 'NUMERIC', |
| 121 |
]; |
| 122 |
} elseif ( 'no_end' === $expiry ) { |
| 123 |
// A time limit was chosen but the end date left blank — the risky |
| 124 |
// case. Requires the `set_time_limit` mode so intentionally |
| 125 |
// open-ended ("Forever") coupons fall under the `forever` filter |
| 126 |
// instead. The mirror is 0, or absent on coupons predating it. |
| 127 |
$meta_query[] = [ |
| 128 |
'relation' => 'AND', |
| 129 |
[ |
| 130 |
'key' => self::TIME_TYPE_META, |
| 131 |
'value' => 'set_time_limit', |
| 132 |
'compare' => '=', |
| 133 |
], |
| 134 |
[ |
| 135 |
'relation' => 'OR', |
| 136 |
[ |
| 137 |
'key' => self::END_TS_META, |
| 138 |
'compare' => 'NOT EXISTS', |
| 139 |
], |
| 140 |
[ |
| 141 |
'key' => self::END_TS_META, |
| 142 |
'value' => 0, |
| 143 |
'compare' => '=', |
| 144 |
'type' => 'NUMERIC', |
| 145 |
], |
| 146 |
], |
| 147 |
]; |
| 148 |
} elseif ( 'forever' === $expiry ) { |
| 149 |
// Open-ended by choice: any mode that is not `set_time_limit`, |
| 150 |
// including legacy coupons with the meta unset (default is |
| 151 |
// `forever_time`). |
| 152 |
$meta_query[] = [ |
| 153 |
'relation' => 'OR', |
| 154 |
[ |
| 155 |
'key' => self::TIME_TYPE_META, |
| 156 |
'compare' => 'NOT EXISTS', |
| 157 |
], |
| 158 |
[ |
| 159 |
'key' => self::TIME_TYPE_META, |
| 160 |
'value' => 'set_time_limit', |
| 161 |
'compare' => '!=', |
| 162 |
], |
| 163 |
]; |
| 164 |
} |
| 165 |
|
| 166 |
$args['meta_query'] = $meta_query; |
| 167 |
|
| 168 |
return $args; |
| 169 |
} |
| 170 |
|
| 171 |
public function validate_request_data( stdClass $prepared_post, WP_REST_Request $request ) { |
| 172 |
$meta = $request->get_param( 'meta' ); |
| 173 |
|
| 174 |
$customers = $meta['_storeengine_coupon_valid_customers'] ?? []; |
| 175 |
foreach ( $customers as $customer_id ) { |
| 176 |
if ( ! is_numeric( $customer_id ) || ! get_userdata( $customer_id ) ) { |
| 177 |
return new WP_Error( 'invalid_customer_id', esc_html__( 'Invalid customer ID provided.', 'storeengine' ), [ 'status' => 400 ] ); |
| 178 |
} |
| 179 |
} |
| 180 |
|
| 181 |
$prices = $meta['_storeengine_coupon_valid_prices'] ?? []; |
| 182 |
|
| 183 |
foreach ( $prices as $index => $price_data ) { |
| 184 |
$product_id = absint( $price_data['product_id'] ); |
| 185 |
if ( ! $product_id || ! get_post( $product_id ) ) { |
| 186 |
return new WP_Error( 'invalid_product_id', esc_html( |
| 187 |
sprintf( |
| 188 |
// translators: %s. Item index. |
| 189 |
__( 'Invalid product ID provided at %s item', 'storeengine' ), |
| 190 |
Formatting::append_numeral_suffix( $index + 1 ) |
| 191 |
) |
| 192 |
), [ 'status' => 400 ] ); |
| 193 |
} |
| 194 |
|
| 195 |
$price_ids = array_map( 'absint', $price_data['price_ids'] ); |
| 196 |
foreach ( $price_ids as $price_id ) { |
| 197 |
try { |
| 198 |
$price = Helper::get_price( $price_id ); |
| 199 |
if ( $price->get_product_id() !== $product_id ) { |
| 200 |
return new WP_Error( |
| 201 |
'invalid_price_id', |
| 202 |
esc_html( |
| 203 |
sprintf( |
| 204 |
// translators: %s. Item index. |
| 205 |
__( 'Invalid product & price combination at %s item.', 'storeengine' ), |
| 206 |
Formatting::append_numeral_suffix( $index + 1 ) |
| 207 |
) |
| 208 |
), |
| 209 |
[ 'status' => 400 ] |
| 210 |
); |
| 211 |
} |
| 212 |
} catch ( StoreEngineException $e ) { |
| 213 |
return new WP_Error( 'invalid_price_id', esc_html( |
| 214 |
sprintf( |
| 215 |
// translators: %s. Item index. |
| 216 |
__( 'Invalid price selected at %s item.', 'storeengine' ), |
| 217 |
Formatting::append_numeral_suffix( $index + 1 ) |
| 218 |
) |
| 219 |
), [ 'status' => 400 ] ); |
| 220 |
} |
| 221 |
} |
| 222 |
} |
| 223 |
|
| 224 |
return $prepared_post; |
| 225 |
} |
| 226 |
|
| 227 |
} |
| 228 |
|