handlers ) { $course = new CourseOfferHandler(); $filtered = apply_filters( 'rooster_lms_offer_handlers', array( OfferTarget::COURSE->value => $course ) ); $out = array(); foreach ( is_array( $filtered ) ? $filtered : array() as $key => $handler ) { if ( $handler instanceof OfferHandler && $handler->target()->value === (int) $key ) { $out[ (int) $key ] = $handler; } } $this->handlers = $out; } return $this->handlers; } public function handler( OfferTarget $target, bool $required = true ): ?OfferHandler { $handler = $this->handlers()[ $target->value ] ?? null; if ( null === $handler && $required ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'target' ) ); } return $handler; } public function save( int $product_id, int $variation_id, OfferTarget $target, int $target_id, array $options, Actor $actor ): Offer { if ( $product_id <= 0 || $variation_id < 0 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'product' ) ); } $handler = $this->handler( $target ); $handler->authorize( $target_id, $actor ); $v = self::clean( $options ); $handler->validate( $target_id, $v['scope_ids'] ); $id = (int) Transaction::run( static function () use ( $product_id, $variation_id, $target, $target_id, $v ): int { global $wpdb; $now = Transaction::now(); $scope = array() === $v['scope_ids'] ? null : (string) wp_json_encode( $v['scope_ids'] ); $id = Db::value( 'SELECT id FROM %i WHERE product_id = %d AND variation_id = %d AND target_type = %d AND target_id = %d FOR UPDATE', Db::table( 'offers' ), $product_id, $variation_id, $target->value, $target_id ); if ( null === $id ) { Db::query( 'INSERT INTO %i (product_id, variation_id, target_type, target_id, sale_window, grace_min, seats_per_qty, state, created_at, updated_at) VALUES (%d, %d, %d, %d, %d, %d, %d, %d, %s, %s)', Db::table( 'offers' ), $product_id, $variation_id, $target->value, $target_id, $v['window'], $v['grace_min'], $v['seats_per_qty'], $v['state'], $now, $now ); $id = Db::insert_id(); } else { $id = (int) $id; Db::query( 'UPDATE %i SET sale_window = %d, grace_min = %d, seats_per_qty = %d, state = %d, updated_at = %s WHERE id = %d', Db::table( 'offers' ), $v['window'], $v['grace_min'], $v['seats_per_qty'], $v['state'], $now, $id ); } Db::query( null === $scope ? SqlPart::of( 'UPDATE %i SET scope_ids = NULL WHERE id = %d', Db::table( 'offers' ), $id ) : SqlPart::of( 'UPDATE %i SET scope_ids = %s WHERE id = %d', Db::table( 'offers' ), $scope, $id ) ); Db::query( null === $v['access_days'] ? SqlPart::of( 'UPDATE %i SET access_days = NULL WHERE id = %d', Db::table( 'offers' ), $id ) : SqlPart::of( 'UPDATE %i SET access_days = %d WHERE id = %d', Db::table( 'offers' ), $v['access_days'], $id ) ); return $id; } ); return $this->get( $id ); } public function set_state( int $id, OfferState $state, Actor $actor ): Offer { $offer = $this->get( $id ); if ( null === $offer ) { DomainError::raise( ErrorCode::NOT_FOUND ); } $this->handler( $offer->target_type )->authorize( $offer->target_id, $actor ); Transaction::run( static function () use ( $id, $state ): void { Db::row( 'SELECT id FROM %i WHERE id = %d FOR UPDATE', Db::table( 'offers' ), $id ); Db::query( 'UPDATE %i SET state = %d, updated_at = %s WHERE id = %d', Db::table( 'offers' ), $state->value, Transaction::now(), $id ); } ); return $this->get( $id ); } public function delete( int $id, Actor $actor ): bool { $offer = $this->get( $id ); if ( null === $offer ) { return false; } $this->handler( $offer->target_type )->authorize( $offer->target_id, $actor ); Transaction::run( static function () use ( $id ): void { if ( null !== Db::value( 'SELECT id FROM %i WHERE offer_id = %d LIMIT 1 LOCK IN SHARE MODE', Db::table( 'sales' ), $id ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'sold' ) ); } Db::query( 'DELETE FROM %i WHERE id = %d', Db::table( 'offers' ), $id ); } ); return true; } public function get( int $id ): ?Offer { $row = Db::row( 'SELECT * FROM %i WHERE id = %d', Db::table( 'offers' ), $id ); return null === $row ? null : Offer::from_row( $row ); } public function for_product( int $product_id, int $variation_id = 0, bool $all = false ): array { global $wpdb; $sql = SqlPart::of( 'SELECT * FROM %i WHERE product_id = %d AND variation_id IN (0, %d)', Db::table( 'offers' ), $product_id, $variation_id ); if ( ! $all ) { $sql = $sql->add( SqlPart::of( ' AND state = %d', OfferState::ACTIVE->value ) ); } return array_map( array( Offer::class, 'from_row' ), Db::rows( $sql->add( ' ORDER BY variation_id DESC, id' ) ) ); } public function for_products( array $product_ids, bool $all = false ): array { $product_ids = CourseRepository::ids( $product_ids ); $out = array_fill_keys( $product_ids, array() ); if ( array() === $product_ids ) { return $out; } global $wpdb; $sql = SqlPart::of( 'SELECT * FROM %i WHERE product_id IN (' . implode( ',', array_fill( 0, count( $product_ids ), '%d' ) ) . ')', array_merge( array( Db::table( 'offers' ) ), $product_ids ) ); if ( ! $all ) { $sql = $sql->add( SqlPart::of( ' AND state = %d', OfferState::ACTIVE->value ) ); } foreach ( Db::rows( $sql->add( ' ORDER BY product_id, variation_id DESC, id' ) ) as $row ) { $offer = Offer::from_row( $row ); $out[ $offer->product_id ][] = $offer; } return $out; } public function for_target( OfferTarget $target, int $target_id ): array { return array_map( array( Offer::class, 'from_row' ), Db::rows( 'SELECT * FROM %i WHERE target_type = %d AND target_id = %d ORDER BY state, product_id, variation_id', Db::table( 'offers' ), $target->value, $target_id ) ); } public function on_sale( Offer $offer, ?\DateTimeImmutable $now = null ): bool { $handler = $this->handler( $offer->target_type, false ); return OfferState::ACTIVE === $offer->state && null !== $handler && $handler->on_sale( $offer, $now ?? PhaseService::now() ); } public function forget(): void { $this->handlers = null; } private static function clean( array $o ): array { $window = $o['window'] ?? OfferWindow::ALWAYS; $window = $window instanceof OfferWindow ? $window : OfferWindow::tryFrom( (int) $window ); $state = $o['state'] ?? OfferState::ACTIVE; $state = $state instanceof OfferState ? $state : OfferState::tryFrom( (int) $state ); if ( null === $window ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'window' ) ); } if ( null === $state ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'state' ) ); } $days = array_key_exists( 'access_days', $o ) && null !== $o['access_days'] && '' !== $o['access_days'] ? (int) $o['access_days'] : null; $v = array( 'scope_ids' => CourseRepository::ids( (array) ( $o['scope_ids'] ?? array() ) ), 'window' => $window->value, 'grace_min' => (int) ( $o['grace_min'] ?? 0 ), 'access_days' => null === $days || 0 === $days ? null : $days, 'seats_per_qty' => (int) ( $o['seats_per_qty'] ?? 1 ), 'state' => $state->value, ); sort( $v['scope_ids'] ); if ( $v['grace_min'] < 0 || $v['grace_min'] > 1440 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'grace_min' ) ); } if ( null !== $v['access_days'] && ( $v['access_days'] < 1 || $v['access_days'] > 36500 ) ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'access_days' ) ); } if ( $v['seats_per_qty'] < 1 || $v['seats_per_qty'] > 100000 ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'seats_per_qty' ) ); } return $v; } }