schedule = $schedule; $this->prefix = $prefix; $this->start = $start; $this->now = $now; } public function issue( array $door, int $doorNo, ?string $email ): array { $coupon = $this->build( $door, $doorNo ); $coupon->set_usage_limit( 1 ); $coupon->set_usage_limit_per_user( 1 ); if ( $email ) { $coupon->set_email_restrictions( [ $email ] ); } $coupon->set_date_expires( $this->expiry( $door )->getTimestamp() ); $coupon->save(); return self::shape( $coupon ); } public function shared( array $door, int $doorNo ): array { $option = self::sharedOption( $this->start, $doorNo ); $storedId = (int) get_option( $option, 0 ); $existing = $this->load( $storedId ); if ( ! $existing ) { if ( $storedId ) { // The shop owner deleted the coupon; start over with a new one. delete_option( $option ); } $coupon = $this->build( $door, $doorNo ); $coupon->set_date_expires( $this->expiry( $door )->getTimestamp() ); $coupon->save(); if ( self::claim( $option, $coupon->get_id() ) ) { return self::shape( $coupon ); } $this->discard( $coupon->get_id() ); $existing = $this->load( (int) get_option( $option, 0 ) ); if ( ! $existing ) { throw new \RuntimeException( 'Shared coupon could not be created.' ); } } // Late visitors (missed days allowed) still get a code that works for the full validity. $expires = $this->expiry( $door )->getTimestamp(); $current = $existing->get_date_expires(); if ( $current && $current->getTimestamp() < $expires ) { $existing->set_date_expires( $expires ); $existing->save(); } return self::shape( $existing ); } /** * Option holding the id of a door's shared (open mode) coupon for one calendar run. */ public static function sharedOption( string $start, int $doorNo ): string { return 'advently_shared_' . str_replace( '-', '', $start ) . '_' . $doorNo; } public function find( int $couponId ): ?array { $coupon = $this->load( $couponId ); return $coupon ? self::shape( $coupon ) : null; } public function discard( int $couponId ): void { if ( $this->load( $couponId ) ) { wp_delete_post( $couponId, true ); } } private function build( array $door, int $doorNo ): \WC_Coupon { $settings = $door['coupon']; $coupon = new \WC_Coupon(); $coupon->set_code( $this->uniqueCode( $doorNo ) ); /* translators: %d: door number. */ $coupon->set_description( sprintf( __( 'Advently advent calendar – door %d', 'advently' ), $doorNo ) ); $coupon->set_individual_use( $settings['individual_use'] ); $coupon->set_minimum_amount( $settings['min_spend'] > 0 ? (string) $settings['min_spend'] : '' ); $coupon->set_product_categories( $settings['category_ids'] ); $coupon->set_product_ids( $settings['product_ids'] ); switch ( $settings['kind'] ) { case 'fixed_cart': $coupon->set_discount_type( 'fixed_cart' ); $coupon->set_amount( $settings['amount'] ); break; case 'free_shipping': $coupon->set_discount_type( 'fixed_cart' ); $coupon->set_amount( 0 ); $coupon->set_free_shipping( true ); break; default: $coupon->set_discount_type( 'percent' ); $coupon->set_amount( $settings['amount'] ); } $coupon->update_meta_data( self::META_DOOR, $doorNo ); $coupon->update_meta_data( self::META_START, $this->start ); /** * Fires before a door's coupon is saved, for coupon kinds that add-ons register. * * @param \WC_Coupon $coupon Unsaved coupon. * @param array $settings The door's coupon settings. * @param int $doorNo Door number. */ do_action( 'advently_build_coupon', $coupon, $settings, $doorNo ); return $coupon; } /** * Validity hours after now, or the end of the calendar when set to 0. */ private function expiry( array $door ): \DateTimeImmutable { $hours = (int) $door['coupon']['validity_hours']; return $hours > 0 ? $this->now->modify( '+' . $hours . ' hours' ) : $this->schedule->end(); } private function uniqueCode( int $doorNo ): string { for ( $i = 0; $i < 5; $i++ ) { $code = CodeGenerator::generate( $this->prefix, $doorNo ); if ( ! wc_get_coupon_id_by_code( $code ) ) { return $code; } } throw new \RuntimeException( 'No free coupon code after 5 attempts.' ); } /** * Stores the shared coupon id only if no parallel request stored one first. * add_option() cannot do this: it upserts and reports success either way. */ private static function claim( string $option, int $couponId ): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- atomic insert-if-absent, no API offers it. $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $option, (string) $couponId ) ); wp_cache_delete( $option, 'options' ); wp_cache_delete( 'notoptions', 'options' ); return 1 === $inserted; } private function load( int $couponId ): ?\WC_Coupon { if ( $couponId <= 0 || 'shop_coupon' !== get_post_type( $couponId ) || 'trash' === get_post_status( $couponId ) ) { return null; } $coupon = new \WC_Coupon( $couponId ); return $coupon->get_id() ? $coupon : null; } private static function shape( \WC_Coupon $coupon ): array { $expires = $coupon->get_date_expires(); return [ 'id' => $coupon->get_id(), 'code' => strtoupper( $coupon->get_code() ), 'expires' => $expires ? $expires->format( DATE_ATOM ) : null, ]; } }