order_item_id <= 0 || $line->user_id <= 0 || null === Users::get( $line->user_id ) ) { DomainError::raise( ErrorCode::NOT_FOUND ); } $existing = $this->by_item( $line->order_item_id ); if ( null !== $existing ) { return $existing; } $offer = Services::offers()->get( $line->offer_id ); if ( null === $offer ) { DomainError::raise( ErrorCode::NOT_FOUND ); } if ( OfferState::ACTIVE !== $offer->state ) { DomainError::raise( ErrorCode::INVALID, array( 'reason' => 'offer' ) ); } $handler = Services::offers()->handler( $offer->target_type ); $trusted = $actor->is_trusted() ? $actor : Actor::system(); $id = (int) Transaction::run( static function () use ( $line, $offer, $handler, $trusted ): int { Services::people()->lock( $line->user_id ); $id = Db::value( 'SELECT id FROM %i WHERE order_item_id = %d FOR UPDATE', Db::table( 'sales' ), $line->order_item_id ); if ( null !== $id ) { return (int) $id; } $granted = $handler->grant( $offer, $line, $trusted ); $now = Transaction::now(); Db::query( 'INSERT INTO %i (order_item_id, order_id, user_id, offer_id, qty, grant_ids, assignment_id, state, flags, created_at, updated_at) VALUES (%d, %d, %d, %d, %d, %s, %d, %d, %d, %s, %s)', Db::table( 'sales' ), $line->order_item_id, $line->order_id, $line->user_id, $offer->id, max( 1, $line->qty ), (string) wp_json_encode( array_values( $granted->grant_ids ) ), $granted->assignment_id, SaleState::GRANTED->value, $granted->flags, $now, $now ); $id = Db::insert_id(); if ( null !== $granted->access_until ) { Db::query( 'UPDATE %i SET access_until = %s WHERE id = %d', Db::table( 'sales' ), $granted->access_until, $id ); } ( new EventWriter() )->record( new Event( Verb::SALE_RECORDED, $trusted, $line->user_id, $offer->is_course_based() ? $offer->target_id : 0, 0, 0, 0, (float) SaleState::GRANTED->value, $id ) ); self::hook( $id, SaleState::GRANTED, $granted->flags ); return $id; } ); return $this->get( $id ); } public function record_refund( int $order_item_id, bool $full, Actor $actor ): ?Sale { return $full ? $this->finish( $order_item_id, SaleState::REFUNDED, $actor ) : $this->by_item( $order_item_id ); } public function record_cancelled( int $order_item_id, Actor $actor ): ?Sale { return $this->finish( $order_item_id, SaleState::CANCELLED, $actor ); } public function expire_due( int $limit = 200 ): int { $items = array_map( 'intval', Db::col( 'SELECT /* outside-tx */ order_item_id FROM %i WHERE state = %d AND access_until IS NOT NULL AND access_until <= %s ORDER BY access_until, id LIMIT %d', Db::table( 'sales' ), SaleState::GRANTED->value, Clock::now(), max( 1, $limit ) ) ); $n = 0; foreach ( $items as $item ) { $sale = $this->finish( $item, SaleState::EXPIRED, Actor::job() ); $n += null !== $sale && SaleState::EXPIRED === $sale->state ? 1 : 0; } return $n; } public function reconcile_course_access( int $user_id, int $course_id, Actor $actor ): void { Transaction::run( static function () use ( $user_id, $course_id, $actor ): void { Services::people()->lock( $user_id ); $rows = Db::rows( 'SELECT s.id, s.access_until, o.target_type, o.scope_ids FROM %i s JOIN %i o ON o.id = s.offer_id WHERE s.user_id = %d AND s.state = %d AND o.target_type IN (%d, %d, %d) AND o.target_id = %d ORDER BY s.id FOR UPDATE', Db::table( 'sales' ), Db::table( 'offers' ), $user_id, SaleState::GRANTED->value, OfferTarget::COURSE->value, OfferTarget::SECTIONS->value, OfferTarget::LESSON->value, $course_id ); $grant = Db::row( 'SELECT id, sources, revoked_at FROM %i WHERE org_id = 0 AND user_id = %d AND course_id = %d FOR UPDATE', Db::table( 'grants' ), $user_id, $course_id ); if ( null === $grant || null !== $grant['revoked_at'] ) { return; } $grant_id = (int) $grant['id']; $imported = false; foreach ( Db::col( 'SELECT grant_ids FROM %i WHERE user_id = %d AND state = %d AND offer_id = 0 FOR UPDATE', Db::table( 'sales' ), $user_id, SaleState::GRANTED->value ) as $json ) { $ids = json_decode( (string) $json, true ); if ( is_array( $ids ) && in_array( $grant_id, array_map( 'intval', $ids ), true ) ) { $imported = true; break; } } if ( $imported ) { $rows[] = array( 'target_type' => OfferTarget::COURSE->value, 'scope_ids' => '', 'access_until' => null, ); } if ( array() === $rows ) { if ( 0 !== ( (int) $grant['sources'] & GrantSource::PURCHASE->value ) ) { Services::grants()->revoke( 0, $user_id, $course_id, GrantSource::PURCHASE, $actor ); } return; } $whole = false; $sections = array(); $items = array(); $until = ''; foreach ( $rows as $row ) { $type = OfferTarget::tryFrom( (int) $row['target_type'] ); $scope = json_decode( (string) ( $row['scope_ids'] ?? '' ), true ); $scope = is_array( $scope ) ? array_map( 'intval', $scope ) : array(); if ( OfferTarget::COURSE === $type ) { $whole = true; } elseif ( OfferTarget::SECTIONS === $type ) { $sections = array_merge( $sections, $scope ); } else { $items = array_merge( $items, $scope ); } $until = null === $row['access_until'] || null === $until ? null : max( $until, (string) $row['access_until'] ); } if ( $whole ) { Services::grant_scope()->clear( $grant_id, $actor ); } else { Services::grant_scope()->replace( $grant_id, $sections, $items, $actor ); } if ( GrantSource::PURCHASE->value === (int) $grant['sources'] ) { if ( null === $until ) { Db::query( 'UPDATE %i SET expires_at = NULL WHERE id = %d', Db::table( 'grants' ), $grant_id ); } elseif ( '' !== $until ) { Db::query( 'UPDATE %i SET expires_at = %s WHERE id = %d', Db::table( 'grants' ), $until, $grant_id ); } } Services::grant_repo()->forget( $user_id ); Services::gate()->forget(); } ); } public function settle_pending( int $limit = 200 ): int { if ( true !== Settings::get( 'sales_end_pending' ) ) { return 0; } global $wpdb; $limit = max( 1, $limit ); $rows = Db::rows( 'SELECT /* outside-tx */ id, user_id, offer_id FROM %i WHERE state IN (%d, %d, %d) AND ( flags & %d ) <> 0 ORDER BY id LIMIT %d', Db::table( 'sales' ), SaleState::REFUNDED->value, SaleState::CANCELLED->value, SaleState::EXPIRED->value, Sale::FLAG_END_PENDING, $limit ); $n = 0; $left = count( $rows ) >= $limit; foreach ( $rows as $row ) { $offer = Services::offers()->get( (int) $row['offer_id'] ); $handler = null === $offer ? null : Services::offers()->handler( $offer->target_type, false ); if ( null !== $offer && null === $handler ) { $left = true; continue; } $id = (int) $row['id']; $user = (int) $row['user_id']; Transaction::run( static function () use ( $id, $user, $offer, $handler, &$n ): void { Services::people()->lock( $user ); $locked = Db::row( 'SELECT * FROM %i WHERE id = %d FOR UPDATE', Db::table( 'sales' ), $id ); $sale = null === $locked ? null : Sale::from_row( $locked ); if ( null === $sale || 0 === ( $sale->flags & Sale::FLAG_END_PENDING ) || $sale->is_granted() ) { return; } Db::query( 'UPDATE %i SET flags = flags & %d, updated_at = %s WHERE id = %d', Db::table( 'sales' ), 0xFF & ~Sale::FLAG_END_PENDING, Transaction::now(), $id ); if ( null !== $offer && null !== $handler ) { $handler->end( $offer, $sale, Actor::job() ); ++$n; } } ); } if ( ! $left ) { Settings::update( array( 'sales_end_pending' => false ) ); } return $n; } public function get( int $id ): ?Sale { $row = Db::row( 'SELECT /* outside-tx */ * FROM %i WHERE id = %d', Db::table( 'sales' ), $id ); return null === $row ? null : Sale::from_row( $row ); } public function by_item( int $order_item_id ): ?Sale { $row = Db::row( 'SELECT /* outside-tx */ * FROM %i WHERE order_item_id = %d', Db::table( 'sales' ), $order_item_id ); return null === $row ? null : Sale::from_row( $row ); } public function for_order( int $order_id ): array { return array_map( array( Sale::class, 'from_row' ), Db::rows( 'SELECT /* outside-tx */ * FROM %i WHERE order_id = %d ORDER BY id', Db::table( 'sales' ), $order_id ) ); } public function is_buyer( int $user_id ): bool { return null !== Db::value( 'SELECT /* outside-tx */ id FROM %i WHERE user_id = %d LIMIT 1', Db::table( 'sales' ), $user_id ); } private function finish( int $order_item_id, SaleState $state, Actor $actor ): ?Sale { $sale = $this->by_item( $order_item_id ); if ( null === $sale ) { return null; } if ( ! $sale->is_granted() ) { return $sale; } $offer = Services::offers()->get( $sale->offer_id ); $handler = null === $offer ? null : Services::offers()->handler( $offer->target_type, false ); $trusted = $actor->is_trusted() ? $actor : Actor::system(); Transaction::run( static function () use ( $sale, $state, $offer, $handler, $trusted ): void { Services::people()->lock( $sale->user_id ); $current = (int) Db::value( 'SELECT state FROM %i WHERE id = %d FOR UPDATE', Db::table( 'sales' ), $sale->id ); if ( SaleState::GRANTED->value !== $current ) { return; } $flags = null !== $offer && null === $handler && ! $offer->is_course_based() ? $sale->flags | Sale::FLAG_END_PENDING : $sale->flags; Db::query( 'UPDATE %i SET state = %d, flags = %d, updated_at = %s WHERE id = %d', Db::table( 'sales' ), $state->value, $flags, Transaction::now(), $sale->id ); $ended = new Sale( $sale->id, $sale->order_item_id, $sale->order_id, $sale->user_id, $sale->offer_id, $sale->qty, $sale->grant_ids, $sale->assignment_id, $state, $flags, $sale->access_until, $sale->created_at, Transaction::now() ); if ( null !== $offer && null !== $handler ) { $handler->end( $offer, $ended, $trusted ); } elseif ( null !== $offer && $offer->is_course_based() ) { Services::sales()->reconcile_course_access( $sale->user_id, $offer->target_id, $trusted ); } elseif ( null !== $offer ) { if ( true !== Settings::get( 'sales_end_pending' ) ) { Settings::update( array( 'sales_end_pending' => true ) ); } } else { $grant_ids = array_values( array_filter( array_map( 'intval', $sale->grant_ids ), static fn( int $id ): bool => $id > 0 ) ); $courses = array() === $grant_ids ? array() : Db::col( 'SELECT course_id FROM %i WHERE id IN (' . implode( ',', array_fill( 0, count( $grant_ids ), '%d' ) ) . ') AND user_id = %d AND org_id = 0 LOCK IN SHARE MODE', array_merge( array( Db::table( 'grants' ) ), $grant_ids, array( $sale->user_id ) ) ); foreach ( array_unique( array_map( 'intval', $courses ) ) as $course_id ) { Services::sales()->reconcile_course_access( $sale->user_id, $course_id, $trusted ); } } ( new EventWriter() )->record( new Event( Verb::SALE_RECORDED, $trusted, $sale->user_id, null !== $offer && $offer->is_course_based() ? $offer->target_id : 0, 0, 0, 0, (float) $state->value, $sale->id ) ); self::hook( $sale->id, $state, $flags ); } ); return $this->get( $sale->id ); } private static function hook( int $sale_id, SaleState $state, int $flags ): void { Transaction::after_commit( static function () use ( $sale_id, $state, $flags ): void { do_action( 'rooster_lms_sale_changed', $sale_id, $state->slug(), $flags ); } ); } }