*/ private array $cache = array(); /** * @param string $code Code in any case. * @return object|null Row with id, campaign_id, code, email, source, uses, expires_at, created_at. */ public function find_by_code( string $code ): ?object { global $wpdb; $code = self::normalize( $code ); if ( '' === $code ) { return null; } if ( ! array_key_exists( $code, $this->cache ) ) { $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE code = %s', Tables::codes(), $code ) ); $this->cache[ $code ] = $row ? $row : null; } return $this->cache[ $code ]; } /** * @param int $campaign_id Campaign ID. * @param string $email Recipient. */ public function find_for( int $campaign_id, string $email ): ?object { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE campaign_id = %d AND email = %s', Tables::codes(), $campaign_id, self::normalize_email( $email ) ) ); return $row ? $row : null; } /** * @param string $code Code in any case. */ public function code_exists( string $code ): bool { global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE code = %s', Tables::codes(), self::normalize( $code ) ) ); } /** * @param int $campaign_id Campaign ID. * @return int Personal codes issued in the campaign. */ public function count_personal( int $campaign_id ): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE campaign_id = %d AND email <> ''", Tables::codes(), $campaign_id ) ); } /** * @param int $campaign_id Campaign ID. * @param string $code Code. * @param string $email Recipient, or '' for a shared code. * @param string $source Who asked for the code. * @param string|null $expires_at Expiry in UTC, or null for none. * @return bool False when the code or the campaign/email pair already exists. */ public function insert( int $campaign_id, string $code, string $email, string $source, ?string $expires_at ): bool { global $wpdb; unset( $this->cache[ self::normalize( $code ) ] ); $data = array( 'campaign_id' => $campaign_id, 'code' => self::normalize( $code ), 'email' => '' === $email ? '' : self::normalize_email( $email ), 'source' => substr( sanitize_key( $source ), 0, 60 ), 'uses' => 0, 'expires_at' => $expires_at, 'created_at' => current_time( 'mysql', true ), ); $suppress = $wpdb->suppress_errors( true ); $inserted = $wpdb->insert( Tables::codes(), $data ); $wpdb->suppress_errors( $suppress ); if ( $inserted ) { Dashboard::changed(); } return (bool) $inserted; } /** * Count one more use of a code, but only while it is below its limit. The limit is part of the * UPDATE, so two orders placed at the same moment cannot both take the last use. * * @param int $code_id Code ID. * @param int $limit Most uses, or 0 for no limit. * @return bool True when this call got a use. */ public function take( int $code_id, int $limit ): bool { global $wpdb; $updated = $limit > 0 ? $wpdb->query( $wpdb->prepare( 'UPDATE %i SET uses = uses + 1 WHERE id = %d AND uses < %d', Tables::codes(), $code_id, $limit ) ) : $wpdb->query( $wpdb->prepare( 'UPDATE %i SET uses = uses + 1 WHERE id = %d', Tables::codes(), $code_id ) ); $this->cache = array(); Dashboard::changed(); return 1 === $updated; } /** * @param int $code_id Code ID. */ public function give_back( int $code_id ): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'UPDATE %i SET uses = uses - 1 WHERE id = %d AND uses > 0', Tables::codes(), $code_id ) ); $this->cache = array(); Dashboard::changed(); } /** * The order's current (not released) use of a code. * * @param int $code_id Code ID. * @param int $order_id Order ID. */ public function active_redemption( int $code_id, int $order_id ): ?object { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE code_id = %d AND order_id = %d AND released_at IS NULL', Tables::redemptions(), $code_id, $order_id ) ); return $row ? $row : null; } /** * Current uses of a code by one e-mail address, other than by the given order. * * @param int $code_id Code ID. * @param string $email Customer e-mail. * @param int $except_order Order to leave out, or 0. */ public function count_email_uses( int $code_id, string $email, int $except_order = 0 ): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE code_id = %d AND email = %s AND order_id <> %d AND released_at IS NULL', Tables::redemptions(), $code_id, self::normalize_email( $email ), $except_order ) ); } /** * @param array $data code_id, campaign_id, order_id, email, country, discount_amount, order_total. */ public function record_redemption( array $data ): void { global $wpdb; $data['email'] = self::normalize_email( (string) ( $data['email'] ?? '' ) ); $data['country'] = substr( strtoupper( (string) ( $data['country'] ?? '' ) ), 0, 2 ); $data['redeemed_at'] = current_time( 'mysql', true ); $wpdb->insert( Tables::redemptions(), $data ); Dashboard::changed(); } /** * @param int $redemption_id Redemption ID. */ public function close_redemption( int $redemption_id ): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'UPDATE %i SET released_at = %s WHERE id = %d AND released_at IS NULL', Tables::redemptions(), current_time( 'mysql', true ), $redemption_id ) ); Dashboard::changed(); } /** * Recalculate the expiry of every code in a campaign after its end date or valid days changed. * * @param Campaign $campaign The changed campaign. */ public function refresh_expiry( Campaign $campaign ): void { global $wpdb; $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id, created_at FROM %i WHERE campaign_id = %d', Tables::codes(), $campaign->id ) ); foreach ( $rows as $row ) { $wpdb->update( Tables::codes(), array( 'expires_at' => Issuer::expires_at( $campaign, (int) strtotime( $row->created_at . ' UTC' ) ) ), array( 'id' => (int) $row->id ) ); } $this->cache = array(); } /** * @param int $campaign_id Campaign ID. * @param int $limit Maximum number of codes. * @return object[] Codes of a campaign, newest first. */ public function for_campaign( int $campaign_id, int $limit = 500 ): array { global $wpdb; return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE campaign_id = %d ORDER BY id DESC LIMIT %d', Tables::codes(), $campaign_id, $limit ) ); } /** * Personal codes and uses stored for one e-mail address, for the personal data exporter. * * @param string $email E-mail address. * @return array{codes: object[], redemptions: object[]} */ public function for_email( string $email ): array { global $wpdb; $email = self::normalize_email( $email ); if ( '' === $email ) { return array( 'codes' => array(), 'redemptions' => array(), ); } return array( 'codes' => (array) $wpdb->get_results( $wpdb->prepare( 'SELECT c.*, p.name AS campaign FROM %i c LEFT JOIN %i p ON p.id = c.campaign_id WHERE c.email = %s ORDER BY c.id', Tables::codes(), Tables::campaigns(), $email ) ), 'redemptions' => (array) $wpdb->get_results( $wpdb->prepare( 'SELECT r.*, p.name AS campaign FROM %i r LEFT JOIN %i p ON p.id = r.campaign_id WHERE r.email = %s ORDER BY r.id', Tables::redemptions(), Tables::campaigns(), $email ) ), ); } /** * Remove an e-mail address for the personal data eraser. Codes keep working for the shop's * figures but are no longer tied to the person: the address becomes a placeholder that is unique * per code (a campaign may hold one code per address) and can never match a real customer. * * @param string $email E-mail address. * @return int Rows changed. */ public function erase_email( string $email ): int { global $wpdb; $email = self::normalize_email( $email ); if ( '' === $email ) { return 0; } $codes = (int) $wpdb->query( $wpdb->prepare( "UPDATE %i SET email = CONCAT( 'erased-', id, '@invalid' ) WHERE email = %s", Tables::codes(), $email ) ); $uses = (int) $wpdb->query( $wpdb->prepare( "UPDATE %i SET email = '' WHERE email = %s", Tables::redemptions(), $email ) ); $this->cache = array(); return $codes + $uses; } /** * Codes are stored upper case. WooCommerce lower-cases codes, so every lookup goes through here. * * @param string $code Code in any case. */ public static function normalize( string $code ): string { return strtoupper( trim( $code ) ); } /** * @param string $email E-mail address. */ public static function normalize_email( string $email ): string { return strtolower( trim( sanitize_email( $email ) ) ); } }