*/ private array $cache = array(); public function __construct( private CategoryRepository $categories, private CodeRepository $codes ) {} public function find( int $id ): ?Campaign { global $wpdb; if ( ! array_key_exists( $id, $this->cache ) ) { $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', Tables::campaigns(), $id ) ); $this->cache[ $id ] = $row ? Campaign::from_row( $row ) : null; } return $this->cache[ $id ]; } public function find_by_slug( string $slug ): ?Campaign { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE slug = %s', Tables::campaigns(), sanitize_title( $slug ) ) ); return $row ? Campaign::from_row( $row ) : null; } /** * @param string $status 'active', 'ended' or '' for all. * @param string $type 'personal', 'shared' or '' for all. * @return Campaign[] Newest first. */ public function all( string $status = '', string $type = '' ): array { global $wpdb; $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s = '' OR status = %s ) AND ( %s = '' OR type = %s ) ORDER BY created_at DESC", Tables::campaigns(), $status, $status, $type, $type ) ); return array_map( array( Campaign::class, 'from_row' ), $rows ); } /** * Create a campaign from user input. A shared campaign gets its code right away. * * @param array $data Raw values, see sanitize(), plus 'category' (name) and 'shared_code'. * @return Campaign|WP_Error */ public function create( array $data ) { global $wpdb; $row = self::sanitize( $data ); if ( $row instanceof WP_Error ) { return $row; } if ( $this->find_by_slug( $row['slug'] ) ) { return new WP_Error( 'cnwscoupons_campaign_exists', __( 'A campaign with this slug already exists.', 'webstyle-coupons' ) ); } $shared_code = ''; if ( Campaign::TYPE_SHARED === $row['type'] ) { $shared_code = self::normalize_shared_code( (string) ( $data['shared_code'] ?? '' ) ); if ( '' === $shared_code ) { return new WP_Error( 'cnwscoupons_shared_code', __( 'A shared code needs 3 to 40 letters, digits or hyphens.', 'webstyle-coupons' ) ); } if ( ! $this->code_available( $shared_code ) ) { return new WP_Error( 'cnwscoupons_shared_code_taken', __( 'This code is already in use. Choose another.', 'webstyle-coupons' ) ); } } $now = current_time( 'mysql', true ); $category_id = $this->categories->find_or_create( (string) ( $data['category'] ?? '' ) ); $row['category_id'] = $category_id ? $category_id : null; $row['status'] = 'active'; $row['created_at'] = $now; $row['updated_at'] = $now; if ( ! $wpdb->insert( Tables::campaigns(), $row ) ) { return new WP_Error( 'cnwscoupons_campaign_failed', __( 'The campaign could not be saved.', 'webstyle-coupons' ) ); } $campaign = $this->find( (int) $wpdb->insert_id ); if ( $campaign && '' !== $shared_code ) { $this->codes->insert( $campaign->id, $shared_code, '', 'shared', Issuer::expires_at( $campaign, time() ) ); } return $campaign; } /** * Change a campaign. Its slug, type and shared code stay as they are: other plugins and printed * material refer to them. Changing the end date or valid days moves the expiry of codes already * issued, so customers get what the campaign now says. * * @param int $id Campaign ID. * @param array $data Raw values as for create(); 'extensions' is kept when left out. * @return Campaign|WP_Error */ public function update( int $id, array $data ) { global $wpdb; $campaign = $this->find( $id ); if ( ! $campaign ) { return new WP_Error( 'cnwscoupons_campaign_missing', __( 'The campaign does not exist.', 'webstyle-coupons' ) ); } $data['slug'] = $campaign->slug; $data['type'] = $campaign->type; $row = self::sanitize( $data ); if ( $row instanceof WP_Error ) { return $row; } unset( $row['slug'], $row['type'] ); if ( ! array_key_exists( 'extensions', $data ) ) { unset( $row['extensions'] ); } $category_id = $this->categories->find_or_create( (string) ( $data['category'] ?? '' ) ); $row['category_id'] = $category_id ? $category_id : null; $row['updated_at'] = current_time( 'mysql', true ); if ( false === $wpdb->update( Tables::campaigns(), $row, array( 'id' => $id ) ) ) { return new WP_Error( 'cnwscoupons_campaign_failed', __( 'The campaign could not be saved.', 'webstyle-coupons' ) ); } unset( $this->cache[ $id ] ); $updated = $this->find( $id ); if ( $updated && ( $updated->expires_on !== $campaign->expires_on || $updated->valid_days !== $campaign->valid_days ) ) { $this->codes->refresh_expiry( $updated ); } return $updated; } /** * End a campaign, or start it again. An ended campaign issues no new codes; issued personal * codes keep working until they expire, a shared code stops. * * @param int $id Campaign ID. * @param string $status 'active' or 'ended'. */ public function set_status( int $id, string $status ): bool { global $wpdb; if ( ! in_array( $status, Campaign::STATUSES, true ) || ! $this->find( $id ) ) { return false; } $saved = false !== $wpdb->update( Tables::campaigns(), array( 'status' => $status, 'updated_at' => current_time( 'mysql', true ), ), array( 'id' => $id ) ); unset( $this->cache[ $id ] ); return $saved; } /** * Whether a code is free: not one of ours and not one of the shop's own WooCommerce coupons. * * @param string $code Code in any case. */ public function code_available( string $code ): bool { return ! $this->codes->code_exists( $code ) && ! ( function_exists( 'wc_get_coupon_id_by_code' ) && wc_get_coupon_id_by_code( $code ) ); } /** * Store an add-on's settings on a campaign, e.g. Checkout Optimizer Pro's free-shipping choice. * The settings stay when the add-on is deactivated, so they work again when it comes back. * * @param int $campaign_id Campaign ID. * @param string $key Add-on key, e.g. 'cnws-checkout-optimizer-pro'. * @param array $settings Settings; an empty array removes them. */ public function save_extension( int $campaign_id, string $key, array $settings ): bool { global $wpdb; $campaign = $this->find( $campaign_id ); $key = sanitize_key( $key ); if ( ! $campaign || '' === $key ) { return false; } $extensions = $campaign->extensions; if ( $settings ) { $extensions[ $key ] = $settings; } else { unset( $extensions[ $key ] ); } $saved = false !== $wpdb->update( Tables::campaigns(), array( 'extensions' => wp_json_encode( $extensions ), 'updated_at' => current_time( 'mysql', true ), ), array( 'id' => $campaign_id ) ); unset( $this->cache[ $campaign_id ] ); return $saved; } /** * Validate and normalize campaign input into a database row. * * @param array $data Raw values: name, slug, type, discount_type, amount, starts_on, expires_on, * valid_days, code_prefix, max_codes, uses_per_code, max_redemptions, * max_per_email, restrictions (see Restrictions::from_array()), * email_restricted, extensions. * @return array|WP_Error */ public static function sanitize( array $data ) { $name = sanitize_text_field( (string) ( $data['name'] ?? '' ) ); $slug = sanitize_title( (string) ( $data['slug'] ?? $name ) ); $type = (string) ( $data['type'] ?? Campaign::TYPE_PERSONAL ); $kind = (string) ( $data['discount_type'] ?? 'percent' ); if ( '' === $name || '' === $slug ) { return new WP_Error( 'cnwscoupons_campaign_name', __( 'The campaign needs a name.', 'webstyle-coupons' ) ); } if ( ! in_array( $type, Campaign::TYPES, true ) ) { return new WP_Error( 'cnwscoupons_campaign_type', __( 'Unknown campaign type.', 'webstyle-coupons' ) ); } if ( ! in_array( $kind, Campaign::DISCOUNT_TYPES, true ) ) { return new WP_Error( 'cnwscoupons_campaign_discount_type', __( 'Unknown discount type.', 'webstyle-coupons' ) ); } $amount = (float) ( $data['amount'] ?? 0 ); if ( $amount <= 0 || ( 'percent' === $kind && $amount > 100 ) ) { return new WP_Error( 'cnwscoupons_campaign_amount', __( 'The discount must be above zero, and at most 100 for a percentage.', 'webstyle-coupons' ) ); } $starts_on = (string) ( $data['starts_on'] ?? '' ); $expires_on = (string) ( $data['expires_on'] ?? '' ); foreach ( array( $starts_on, $expires_on ) as $date ) { if ( '' !== $date && ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) { return new WP_Error( 'cnwscoupons_campaign_date', __( 'Dates must be written as YYYY-MM-DD.', 'webstyle-coupons' ) ); } } if ( '' !== $starts_on && '' !== $expires_on && $expires_on < $starts_on ) { return new WP_Error( 'cnwscoupons_campaign_dates', __( 'The end date is before the start date.', 'webstyle-coupons' ) ); } $shared = Campaign::TYPE_SHARED === $type; $restrictions = Restrictions::from_array( $data['restrictions'] ?? array() ); if ( null !== $restrictions->minimum_amount && null !== $restrictions->maximum_amount && $restrictions->maximum_amount < $restrictions->minimum_amount ) { return new WP_Error( 'cnwscoupons_campaign_amounts', __( 'The maximum cart amount is below the minimum.', 'webstyle-coupons' ) ); } return array( 'slug' => substr( $slug, 0, 100 ), 'name' => substr( $name, 0, 190 ), 'type' => $type, 'discount_type' => $kind, 'amount' => $amount, 'starts_on' => '' !== $starts_on ? $starts_on : null, 'expires_on' => '' !== $expires_on ? $expires_on : null, 'valid_days' => ! $shared && absint( $data['valid_days'] ?? 0 ) ? absint( $data['valid_days'] ) : null, 'code_prefix' => $shared ? '' : CodeGenerator::normalize_prefix( (string) ( $data['code_prefix'] ?? '' ) ), 'max_codes' => ! $shared && absint( $data['max_codes'] ?? 0 ) ? absint( $data['max_codes'] ) : null, 'uses_per_code' => $shared ? 1 : max( 1, absint( $data['uses_per_code'] ?? 1 ) ), 'max_redemptions' => $shared && absint( $data['max_redemptions'] ?? 0 ) ? absint( $data['max_redemptions'] ) : null, 'max_per_email' => $shared && absint( $data['max_per_email'] ?? 0 ) ? absint( $data['max_per_email'] ) : null, 'restrictions' => wp_json_encode( $restrictions->to_array() ), // A shared code is used by many people, so it cannot be bound to one address. 'email_restricted' => $shared ? 0 : ( array_key_exists( 'email_restricted', $data ) ? (int) (bool) $data['email_restricted'] : 1 ), 'extensions' => wp_json_encode( is_array( $data['extensions'] ?? null ) ? $data['extensions'] : array() ), ); } /** * A shop-chosen code such as FOD20: upper case letters, digits and hyphens, 3 to 40 characters. * * @param string $code Code as typed. * @return string The code, or '' when it is not usable. */ public static function normalize_shared_code( string $code ): string { $code = strtoupper( trim( $code ) ); return preg_match( '/^[A-Z0-9][A-Z0-9-]{1,38}[A-Z0-9]$/', $code ) ? $code : ''; } }