diff --git a/config/shop.php b/config/shop.php index a42b969..1997085 100644 --- a/config/shop.php +++ b/config/shop.php @@ -40,6 +40,7 @@ return [ 'cart_discounts' => 'cart_discounts', 'subscriptions' => 'subscriptions', 'subscription_items' => 'subscription_items', + 'license_seats' => 'license_seats', ], // Model classes (allow overriding in main instance) @@ -59,6 +60,7 @@ return [ 'payment_method' => \Blax\Shop\Models\PaymentMethod::class, 'subscription' => \Blax\Shop\Models\Subscription::class, 'subscription_item' => \Blax\Shop\Models\SubscriptionItem::class, + 'license_seat' => \Blax\Shop\Models\LicenseSeat::class, ], /* @@ -76,6 +78,23 @@ return [ 'canceled_event' => 'subscription.canceled', ], + /* + * Assignable license seats. + * + * When a product is seat-based (Product::isSeatBased() — driven by + * `meta.seat_based = true`), a purchase/subscription of `quantity = N` + * provisions N assignable LicenseSeat rows instead of granting the buyer. + * Assigning a seat fires the product's actions for `assigned_event` with an + * explicit `grantee`; reclaiming/reassigning fires `revoked_event`. Host + * action jobs should honor `grantee` (falling back to the buyer) so the + * same jobs serve both personal purchases and seat assignments. + */ + 'seats' => [ + 'enabled' => env('SHOP_SEATS_ENABLED', true), + 'assigned_event' => 'seat.assigned', + 'revoked_event' => 'seat.revoked', + ], + // API Routes configuration 'routes' => [ 'enabled' => true, diff --git a/database/factories/LicenseSeatFactory.php b/database/factories/LicenseSeatFactory.php new file mode 100644 index 0000000..254727d --- /dev/null +++ b/database/factories/LicenseSeatFactory.php @@ -0,0 +1,33 @@ + Product::factory(), + 'status' => SeatStatus::UNASSIGNED, + 'meta' => [], + ]; + } + + public function assignedTo(Model $assignee): static + { + return $this->state(fn () => [ + 'status' => SeatStatus::ASSIGNED, + 'assignee_id' => (string) $assignee->getKey(), + 'assignee_type' => $assignee->getMorphClass(), + 'assigned_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_01_01_000003_create_license_seats_table.php b/database/migrations/2026_01_01_000003_create_license_seats_table.php new file mode 100644 index 0000000..501aa81 --- /dev/null +++ b/database/migrations/2026_01_01_000003_create_license_seats_table.php @@ -0,0 +1,50 @@ +uuid('id')->primary(); + $table->uuid('product_id'); + // Source: exactly one of these is set — the purchase or subscription + // whose `quantity` this seat is one unit of. + $table->uuid('product_purchase_id')->nullable(); + $table->uuid('subscription_id')->nullable(); + // Who currently holds the seat (polymorphic; null while free). + $table->nullableUuidMorphs('assignee'); + $table->string('status')->default('unassigned'); // unassigned, assigned, revoked + $table->timestamp('assigned_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + // When the seat's grant lapses (subscription period end); null = no + // expiry (one-time purchase seat). + $table->timestamp('expires_at')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['product_id', 'status']); + $table->index(['product_purchase_id', 'status']); + $table->index(['subscription_id', 'status']); + + $table->foreign('product_id') + ->references('id')->on(config('shop.tables.products', 'products')) + ->cascadeOnDelete(); + $table->foreign('product_purchase_id') + ->references('id')->on(config('shop.tables.product_purchases', 'product_purchases')) + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists(config('shop.tables.license_seats', 'license_seats')); + } +}; diff --git a/src/Enums/SeatStatus.php b/src/Enums/SeatStatus.php new file mode 100644 index 0000000..e49b2a1 --- /dev/null +++ b/src/Enums/SeatStatus.php @@ -0,0 +1,31 @@ + 'Unassigned', + self::ASSIGNED => 'Assigned', + self::REVOKED => 'Revoked', + }; + } +} diff --git a/src/Events/SeatAssigned.php b/src/Events/SeatAssigned.php new file mode 100644 index 0000000..d90759c --- /dev/null +++ b/src/Events/SeatAssigned.php @@ -0,0 +1,21 @@ +user`, so passing `grantee` here + * redirects the grant to the seat holder without touching buyer-grant flows. + * + * @property string $id + * @property string $product_id + * @property string|null $product_purchase_id + * @property string|null $subscription_id + * @property string|null $assignee_id + * @property string|null $assignee_type + * @property \Blax\Shop\Enums\SeatStatus $status + * @property \Illuminate\Support\Carbon|null $assigned_at + * @property \Illuminate\Support\Carbon|null $revoked_at + * @property \Illuminate\Support\Carbon|null $expires_at + * @property \stdClass $meta + * + * @property-read Product|null $product + * @property-read ProductPurchase|null $purchase + * @property-read Model|null $assignee + */ +class LicenseSeat extends Model +{ + use HasUuids; + + protected $fillable = [ + 'product_id', + 'product_purchase_id', + 'subscription_id', + 'assignee_id', + 'assignee_type', + 'status', + 'assigned_at', + 'revoked_at', + 'expires_at', + 'meta', + ]; + + protected $casts = [ + 'status' => SeatStatus::class, + 'assigned_at' => 'datetime', + 'revoked_at' => 'datetime', + 'expires_at' => 'datetime', + 'meta' => 'object', + ]; + + protected $attributes = [ + 'status' => 'unassigned', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->setTable(config('shop.tables.license_seats', 'license_seats')); + } + + /** + * The product this seat grants access to. + * + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(config('shop.models.product', Product::class)); + } + + /** + * The one-time purchase that spawned this seat (null for subscription seats). + * + * @return BelongsTo + */ + public function purchase(): BelongsTo + { + return $this->belongsTo( + config('shop.models.product_purchase', ProductPurchase::class), + 'product_purchase_id' + ); + } + + /** + * The subscription that spawned this seat (null for one-time-purchase seats). + * + * @return BelongsTo + */ + public function subscription(): BelongsTo + { + return $this->belongsTo( + config('shop.models.subscription', Subscription::class), + 'subscription_id' + ); + } + + /** + * The user currently holding this seat (polymorphic; null when free). + * + * @return MorphTo + */ + public function assignee(): MorphTo + { + return $this->morphTo('assignee'); + } + + /** @param Builder $query */ + public function scopeUnassigned(Builder $query): Builder + { + return $query->where('status', SeatStatus::UNASSIGNED->value); + } + + /** @param Builder $query */ + public function scopeAssigned(Builder $query): Builder + { + return $query->where('status', SeatStatus::ASSIGNED->value); + } + + /** Seats that still count against the pool (free or held), i.e. not retired. */ + /** @param Builder $query */ + public function scopeActive(Builder $query): Builder + { + return $query->whereIn('status', [ + SeatStatus::UNASSIGNED->value, + SeatStatus::ASSIGNED->value, + ]); + } + + public function isAssigned(): bool + { + return $this->status === SeatStatus::ASSIGNED; + } + + /** + * Hand a free seat to a user and grant them the product's access. If the + * seat is already held by someone else this transparently becomes a + * {@see reassign()} so callers never have to branch. + */ + public function assign(Model $assignee): self + { + if ($this->isAssigned() && $this->assignee_id !== null && ! $this->assigneeIs($assignee)) { + return $this->reassign($assignee); + } + + $this->forceFill([ + 'assignee_id' => (string) $assignee->getKey(), + 'assignee_type' => $assignee->getMorphClass(), + 'status' => SeatStatus::ASSIGNED, + 'assigned_at' => now(), + 'revoked_at' => null, + ])->save(); + + $this->grant($assignee); + SeatAssigned::dispatch($this); + + return $this; + } + + /** + * Move a held seat to a different user in one operation: revoke the previous + * holder's grants, then grant the new holder. No-op grant churn if the seat + * is already assigned to the same user. + */ + public function reassign(Model $assignee): self + { + $previous = $this->assignee; + + if ($previous && ! $this->assigneeIs($assignee)) { + $this->ungrant($previous); + } + + $this->forceFill([ + 'assignee_id' => (string) $assignee->getKey(), + 'assignee_type' => $assignee->getMorphClass(), + 'status' => SeatStatus::ASSIGNED, + 'assigned_at' => now(), + 'revoked_at' => null, + ])->save(); + + $this->grant($assignee); + SeatReassigned::dispatch($this, $previous); + + return $this; + } + + /** + * Take the seat back from its holder and return it to the pool + * (UNASSIGNED), revoking the holder's grants. The seat can be assigned + * again afterwards. + */ + public function reclaim(): self + { + $previous = $this->assignee; + + if ($previous) { + $this->ungrant($previous); + } + + $this->forceFill([ + 'assignee_id' => null, + 'assignee_type' => null, + 'status' => SeatStatus::UNASSIGNED, + 'revoked_at' => now(), + ])->save(); + + SeatRevoked::dispatch($this, $previous); + + return $this; + } + + /** + * Permanently retire the seat (pool shrank, subscription canceled): revoke + * the holder's grants and mark it REVOKED so it is no longer assignable. + */ + public function retire(): self + { + $previous = $this->assignee; + + if ($previous) { + $this->ungrant($previous); + } + + $this->forceFill([ + 'assignee_id' => null, + 'assignee_type' => null, + 'status' => SeatStatus::REVOKED, + 'revoked_at' => now(), + ])->save(); + + SeatRevoked::dispatch($this, $previous); + + return $this; + } + + /** + * Re-run the current holder's grant with the seat's current `expires_at` — + * used on subscription renewal so a held seat's access extends into the new + * billing cycle. No-op for a free seat. + */ + public function refreshGrant(): self + { + if ($this->isAssigned() && $this->assignee) { + $this->grant($this->assignee); + } + + return $this; + } + + /** + * Fire the product's "seat assigned" grant actions against $assignee. Uses + * the same {@see ProductAction} engine as a normal purchase, so the holder + * receives exactly the product's configured access. + */ + protected function grant(Model $assignee): void + { + $product = $this->product; + + if ($product && method_exists($product, 'callActions')) { + $product->callActions( + config('shop.seats.assigned_event', 'seat.assigned'), + $this->purchase, + [ + 'grantee' => $assignee, + 'seat' => $this, + 'expiresAtOverride' => $this->expires_at, + ] + ); + } + } + + /** + * Fire the product's "seat revoked" actions against $assignee so host apps + * can undo exactly what {@see grant()} conferred. + */ + protected function ungrant(Model $assignee): void + { + $product = $this->product; + + if ($product && method_exists($product, 'callActions')) { + $product->callActions( + config('shop.seats.revoked_event', 'seat.revoked'), + $this->purchase, + [ + 'grantee' => $assignee, + 'seat' => $this, + ] + ); + } + } + + protected function assigneeIs(Model $other): bool + { + return (string) $this->assignee_id === (string) $other->getKey() + && $this->assignee_type === $other->getMorphClass(); + } +} diff --git a/src/Models/Product.php b/src/Models/Product.php index 556e5a0..08d0e7f 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -491,6 +491,26 @@ class Product extends Model implements Purchasable, Cartable ); } + /** + * Whether this product is sold as assignable license seats: a purchase or + * subscription of `quantity = N` provisions N {@see LicenseSeat} rows the + * buyer hands out, rather than granting the buyer directly. Opt in per + * product via `meta.seat_based = true`; globally gated by + * `config('shop.seats.enabled')`. + */ + public function isSeatBased(): bool + { + if (! config('shop.seats.enabled', true)) { + return false; + } + + $meta = $this->meta; + + return (bool) (is_object($meta) + ? ($meta->seat_based ?? false) + : ($meta['seat_based'] ?? false)); + } + /** * Visible to customers right now: `is_visible = true`, status PUBLISHED, * and `published_at` either null or in the past. diff --git a/src/Models/ProductPurchase.php b/src/Models/ProductPurchase.php index 070e4dc..a642d3b 100644 --- a/src/Models/ProductPurchase.php +++ b/src/Models/ProductPurchase.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -139,6 +140,20 @@ class ProductPurchase extends Model ); } + /** + * The assignable license seats this (seat-based) purchase provisioned — + * one row per unit of `quantity`. Empty for ordinary products. + * + * @return HasMany + */ + public function seats(): HasMany + { + return $this->hasMany( + config('shop.models.license_seat', LicenseSeat::class), + 'product_purchase_id' + ); + } + /** * Resolve the purchaser as a User relation when the polymorphic type * matches the configured auth model; returns null otherwise so callers @@ -225,6 +240,18 @@ class ProductPurchase extends Model $product = static::resolveActionableProduct($productPurchase); if ($product && method_exists($product, 'callActions')) { + // Seat-based products hand the buyer a pool of assignable seats + // instead of granting the buyer directly. Provisioning is + // idempotent, so duplicate completions (e.g. re-fired webhooks) + // don't over-provision. + if ( + config('shop.seats.enabled', true) + && method_exists($product, 'isSeatBased') + && $product->isSeatBased() + ) { + app(\Blax\Shop\Services\SeatService::class)->provisionForPurchase($productPurchase); + } + $product->callActions('purchased', $productPurchase); } } diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index ff5f684..f44de0f 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -65,6 +65,21 @@ class Subscription extends CashierSubscription ); } + /** + * Assignable license seats this (seat-based) subscription provisions — + * sized to the billed quantity and expiring at the period end. Empty for + * ordinary subscriptions. + * + * @return HasMany + */ + public function seats(): HasMany + { + return $this->hasMany( + config('shop.models.license_seat', LicenseSeat::class), + 'subscription_id' + ); + } + /** * Resolve (and cache) the product this subscription sells: the linked * `product_id` first, else the first item's `stripe_product` mapped to a @@ -168,6 +183,33 @@ class Subscription extends CashierSubscription } } + /** + * For each seat-based product on this subscription, ensure the seat pool + * matches the billed quantity and every held seat's grant is valid until + * `$expiresAt`. No-op for ordinary (non-seat) products, so this is safe to + * call from every lifecycle hook. + */ + protected function provisionSeats(?\Carbon\Carbon $expiresAt = null): void + { + if (! config('shop.seats.enabled', true)) { + return; + } + + $seatService = app(\Blax\Shop\Services\SeatService::class); + + foreach ($this->resolveProducts() as $entry) { + $product = $entry['product'] ?? null; + $item = $entry['item'] ?? null; + + if (! $product || ! method_exists($product, 'isSeatBased') || ! $product->isSeatBased()) { + continue; + } + + $quantity = (int) ($item?->quantity ?? $this->quantity ?? 1); + $seatService->provisionForSubscription($this, $product, $quantity, $expiresAt); + } + } + /** * Mark a new subscription as started: fire {@see SubscriptionStarted} and * run the product's actions for the configured "started" event. @@ -175,6 +217,7 @@ class Subscription extends CashierSubscription public function recordStarted(?\Carbon\Carbon $expiresAtOverride = null): void { $this->callProductActions($expiresAtOverride, config('shop.subscriptions.started_event', 'subscription.started')); + $this->provisionSeats($expiresAtOverride); SubscriptionStarted::dispatch($this); } @@ -185,6 +228,7 @@ class Subscription extends CashierSubscription public function recordRenewed(?\Carbon\Carbon $expiresAtOverride = null): void { $this->callProductActions($expiresAtOverride, config('shop.subscriptions.renewed_event', 'subscription.renewed')); + $this->provisionSeats($expiresAtOverride); SubscriptionRenewed::dispatch($this); } diff --git a/src/Services/SeatService.php b/src/Services/SeatService.php new file mode 100644 index 0000000..1d679ff --- /dev/null +++ b/src/Services/SeatService.php @@ -0,0 +1,110 @@ + + */ + public function provisionForPurchase(ProductPurchase $purchase): Collection + { + $product = $purchase->product ?? $purchase->purchasable; + + if (! $product) { + return collect(); + } + + return $this->ensureSeats( + (string) $product->getKey(), + ['product_purchase_id' => (string) $purchase->getKey()], + max(0, (int) $purchase->quantity), + null, + ); + } + + /** + * Ensure a subscription has `quantity` seats and that every seat's grant is + * valid until `$expiresAt`. On renewal this extends the expiry of held + * seats and re-fires their grants so access rolls into the new cycle. + * + * @return Collection + */ + public function provisionForSubscription( + Subscription $subscription, + Model $product, + int $quantity, + ?Carbon $expiresAt = null, + ): Collection { + $seats = $this->ensureSeats( + (string) $product->getKey(), + ['subscription_id' => (string) $subscription->getKey()], + max(0, $quantity), + $expiresAt, + ); + + foreach ($seats as $seat) { + if ($expiresAt && (! $seat->expires_at || ! $seat->expires_at->equalTo($expiresAt))) { + $seat->forceFill(['expires_at' => $expiresAt])->save(); + } + + if ($seat->status === SeatStatus::ASSIGNED) { + $seat->refreshGrant(); + } + } + + return $seats; + } + + /** + * Get (or top up to) `$target` active seats for a source, creating any that + * are missing as UNASSIGNED. Never destroys existing seats — shrinking a + * pool is a deliberate `retire()` decision left to the caller. + * + * @param array $source + * @return Collection + */ + protected function ensureSeats(string $productId, array $source, int $target, ?Carbon $expiresAt): Collection + { + /** @var class-string $model */ + $model = config('shop.models.license_seat', LicenseSeat::class); + + $seats = $model::query() + ->where($source) + ->whereIn('status', [SeatStatus::UNASSIGNED->value, SeatStatus::ASSIGNED->value]) + ->orderBy('created_at') + ->get(); + + for ($i = $seats->count(); $i < $target; $i++) { + $seats->push($model::create(array_merge($source, [ + 'product_id' => $productId, + 'status' => SeatStatus::UNASSIGNED, + 'expires_at' => $expiresAt, + ]))); + } + + return $seats; + } +} diff --git a/tests/Feature/Seats/LicenseSeatTest.php b/tests/Feature/Seats/LicenseSeatTest.php new file mode 100644 index 0000000..536cef8 --- /dev/null +++ b/tests/Feature/Seats/LicenseSeatTest.php @@ -0,0 +1,287 @@ + */ + public static array $calls = []; + + public static function reset(): void + { + self::$calls = []; + } + + public static function handle(...$args): void + { + self::$calls[] = [ + 'event' => $args['event'] ?? null, + 'grantee_id' => isset($args['grantee']) ? (string) $args['grantee']->getKey() : null, + 'seat_id' => isset($args['seat']) ? (string) $args['seat']->getKey() : null, + ]; + } + + /** @return array grantee ids granted/revoked for $event, in order */ + public static function granteesFor(string $event): array + { + return array_values(array_filter(array_map( + fn ($c) => $c['event'] === $event ? $c['grantee_id'] : null, + self::$calls, + ))); + } +} + +class LicenseSeatTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + SeatGrantSpy::reset(); + } + + private function seatBasedProduct(): Product + { + $product = Product::factory()->create([ + 'meta' => ['seat_based' => true], + 'status' => 'published', + 'is_visible' => true, + ]); + + // One action serving both seat lifecycle events, recording the grantee. + ProductAction::create([ + 'product_id' => $product->id, + 'events' => ['seat.assigned', 'seat.revoked'], + 'class' => SeatGrantSpy::class, + 'method' => 'handle', + 'defer' => false, + 'active' => true, + ]); + + return $product; + } + + private function completedPurchase(Product $product, User $buyer, int $quantity): ProductPurchase + { + return ProductPurchase::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => get_class($product), + 'purchaser_id' => $buyer->getKey(), + 'purchaser_type' => $buyer->getMorphClass(), + 'quantity' => $quantity, + 'status' => PurchaseStatus::COMPLETED, + 'amount' => 1000 * $quantity, + ]); + } + + public function test_completed_seat_based_purchase_provisions_one_seat_per_quantity(): void + { + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + + $purchase = $this->completedPurchase($product, $buyer, 3); + + $this->assertSame(3, $purchase->seats()->count()); + $this->assertSame(3, $purchase->seats()->where('status', SeatStatus::UNASSIGNED->value)->count()); + } + + public function test_provisioning_is_idempotent(): void + { + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + $purchase = $this->completedPurchase($product, $buyer, 2); + + // Re-running fulfillment (a duplicate / updated webhook) must not + // over-provision. + $purchase->update(['meta' => (object) ['touched' => true]]); + + $this->assertSame(2, $purchase->seats()->count()); + } + + public function test_non_seat_product_provisions_no_seats(): void + { + $product = Product::factory()->create([ + 'meta' => ['seat_based' => false], + 'status' => 'published', + 'is_visible' => true, + ]); + $buyer = User::factory()->create(); + + $purchase = $this->completedPurchase($product, $buyer, 4); + + $this->assertSame(0, $purchase->seats()->count()); + } + + public function test_assigning_a_seat_grants_the_holder_not_the_buyer(): void + { + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + $student = User::factory()->create(); + $purchase = $this->completedPurchase($product, $buyer, 1); + $seat = $purchase->seats()->first(); + + Event::fake([SeatAssigned::class]); + SeatGrantSpy::reset(); + + $seat->assign($student); + $seat->refresh(); + + $this->assertSame(SeatStatus::ASSIGNED, $seat->status); + $this->assertSame((string) $student->getKey(), (string) $seat->assignee_id); + + $grantees = SeatGrantSpy::granteesFor('seat.assigned'); + $this->assertSame([(string) $student->getKey()], $grantees); + $this->assertNotContains((string) $buyer->getKey(), $grantees); + + Event::assertDispatched(SeatAssigned::class); + } + + public function test_reassigning_moves_access_from_one_holder_to_another(): void + { + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + $a = User::factory()->create(); + $b = User::factory()->create(); + $purchase = $this->completedPurchase($product, $buyer, 1); + $seat = $purchase->seats()->first(); + + $seat->assign($a); + + Event::fake([SeatReassigned::class]); + SeatGrantSpy::reset(); + + $seat->reassign($b); + $seat->refresh(); + + $this->assertSame((string) $b->getKey(), (string) $seat->assignee_id); + $this->assertSame([(string) $a->getKey()], SeatGrantSpy::granteesFor('seat.revoked')); + $this->assertSame([(string) $b->getKey()], SeatGrantSpy::granteesFor('seat.assigned')); + + Event::assertDispatched(SeatReassigned::class); + } + + public function test_reclaiming_frees_the_seat_and_revokes_access(): void + { + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + $student = User::factory()->create(); + $purchase = $this->completedPurchase($product, $buyer, 1); + $seat = $purchase->seats()->first(); + + $seat->assign($student); + + Event::fake([SeatRevoked::class]); + SeatGrantSpy::reset(); + + $seat->reclaim(); + $seat->refresh(); + + $this->assertSame(SeatStatus::UNASSIGNED, $seat->status); + $this->assertNull($seat->assignee_id); + $this->assertSame([(string) $student->getKey()], SeatGrantSpy::granteesFor('seat.revoked')); + + Event::assertDispatched(SeatRevoked::class); + } + + public function test_retiring_a_seat_revokes_access_and_leaves_it_unassignable(): void + { + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + $student = User::factory()->create(); + $purchase = $this->completedPurchase($product, $buyer, 1); + $seat = $purchase->seats()->first(); + $seat->assign($student); + + SeatGrantSpy::reset(); + $seat->retire(); + $seat->refresh(); + + $this->assertSame(SeatStatus::REVOKED, $seat->status); + $this->assertNull($seat->assignee_id); + $this->assertSame([(string) $student->getKey()], SeatGrantSpy::granteesFor('seat.revoked')); + // A retired seat no longer counts against the pool. + $this->assertSame(0, $purchase->seats()->active()->count()); + } + + public function test_refresh_grant_refires_the_holders_grant(): void + { + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + $student = User::factory()->create(); + $purchase = $this->completedPurchase($product, $buyer, 1); + $seat = $purchase->seats()->first(); + $seat->assign($student); + + SeatGrantSpy::reset(); + $seat->refreshGrant(); + + $this->assertSame([(string) $student->getKey()], SeatGrantSpy::granteesFor('seat.assigned')); + } + + public function test_assigning_a_held_seat_delegates_to_reassign(): void + { + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + $a = User::factory()->create(); + $b = User::factory()->create(); + $purchase = $this->completedPurchase($product, $buyer, 1); + $seat = $purchase->seats()->first(); + + $seat->assign($a); + SeatGrantSpy::reset(); + + // Assigning an already-held seat to a different user moves it. + $seat->assign($b); + $seat->refresh(); + + $this->assertSame((string) $b->getKey(), (string) $seat->assignee_id); + $this->assertSame([(string) $a->getKey()], SeatGrantSpy::granteesFor('seat.revoked')); + $this->assertSame([(string) $b->getKey()], SeatGrantSpy::granteesFor('seat.assigned')); + } + + public function test_reassigning_to_the_same_holder_does_not_revoke(): void + { + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + $student = User::factory()->create(); + $purchase = $this->completedPurchase($product, $buyer, 1); + $seat = $purchase->seats()->first(); + + $seat->assign($student); + SeatGrantSpy::reset(); + + $seat->reassign($student); + + $this->assertSame([], SeatGrantSpy::granteesFor('seat.revoked')); + $this->assertSame([(string) $student->getKey()], SeatGrantSpy::granteesFor('seat.assigned')); + } + + public function test_seats_disabled_globally_provisions_nothing(): void + { + config()->set('shop.seats.enabled', false); + + $product = $this->seatBasedProduct(); + $buyer = User::factory()->create(); + + $purchase = $this->completedPurchase($product, $buyer, 3); + + $this->assertSame(0, $purchase->seats()->count()); + } +} diff --git a/tests/Feature/Seats/SeatSubscriptionTest.php b/tests/Feature/Seats/SeatSubscriptionTest.php new file mode 100644 index 0000000..7097fac --- /dev/null +++ b/tests/Feature/Seats/SeatSubscriptionTest.php @@ -0,0 +1,144 @@ + 'School Seats', + 'sku' => 'SEATS-'.uniqid(), + 'type' => ProductType::SUBSCRIPTION, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => false, + 'meta' => ['seat_based' => $seatBased], + ]); + + ProductAction::create([ + 'product_id' => $product->id, + 'events' => ['seat.assigned', 'seat.revoked'], + 'class' => SubSeatGrantSpy::class, + 'method' => 'handle', + 'defer' => false, + 'active' => true, + ]); + + return $product; + } + + private function subscriptionFor(Product $product, User $user, int $quantity): Subscription + { + return Subscription::create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + 'type' => 'default', + 'stripe_id' => 'sub_'.uniqid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_x', + 'quantity' => $quantity, + ]); + } + + public function test_record_started_provisions_one_seat_per_quantity_with_expiry(): void + { + $user = User::factory()->create(); + $product = $this->seatProduct(); + $sub = $this->subscriptionFor($product, $user, 5); + + $expires = now()->addMonth(); + $sub->recordStarted($expires); + + $this->assertSame(5, $sub->seats()->count()); + $this->assertSame(5, $sub->seats()->where('status', SeatStatus::UNASSIGNED->value)->count()); + + $seat = $sub->seats()->first(); + $this->assertNotNull($seat->expires_at); + $this->assertSame($expires->toDateTimeString(), $seat->expires_at->toDateTimeString()); + } + + public function test_renewal_extends_expiry_and_refreshes_held_seat_grants(): void + { + $user = User::factory()->create(); + $student = User::factory()->create(); + $product = $this->seatProduct(); + $sub = $this->subscriptionFor($product, $user, 2); + + $sub->recordStarted(now()->addMonth()); + $seat = $sub->seats()->first(); + $seat->assign($student); + + SubSeatGrantSpy::$calls = []; + $newExpiry = now()->addMonths(2); + $sub->recordRenewed($newExpiry); + + $seat->refresh(); + $this->assertSame($newExpiry->toDateTimeString(), $seat->expires_at->toDateTimeString()); + + // The held seat's grant was re-fired for the new cycle, targeting the + // holder (never the subscription owner). + $granted = array_values(array_filter(array_map( + fn ($c) => ($c['event'] ?? null) === 'seat.assigned' && isset($c['grantee']) + ? (string) $c['grantee']->getKey() + : null, + SubSeatGrantSpy::$calls, + ))); + $this->assertContains((string) $student->getKey(), $granted); + } + + public function test_renewal_does_not_over_provision(): void + { + $user = User::factory()->create(); + $product = $this->seatProduct(); + $sub = $this->subscriptionFor($product, $user, 3); + + $sub->recordStarted(now()->addMonth()); + $sub->recordRenewed(now()->addMonths(2)); + + $this->assertSame(3, $sub->seats()->count()); + } + + public function test_non_seat_subscription_provisions_no_seats(): void + { + $user = User::factory()->create(); + $product = $this->seatProduct(seatBased: false); + $sub = $this->subscriptionFor($product, $user, 4); + + $sub->recordStarted(now()->addMonth()); + + $this->assertSame(0, $sub->seats()->count()); + } +} + +/** + * Records the grantee + event of each seat grant action so subscription tests + * can assert renewal re-grants the holder. + */ +class SubSeatGrantSpy +{ + /** @var array> */ + public static array $calls = []; + + public static function handle(...$args): void + { + self::$calls[] = $args; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 33ed3e1..bd38689 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -73,5 +73,8 @@ abstract class TestCase extends Orchestra $migration = include __DIR__ . '/../database/migrations/2025_01_01_000004_create_blax_shop_subscriptions.php'; $migration->up(); + + $migration = include __DIR__ . '/../database/migrations/2026_01_01_000003_create_license_seats_table.php'; + $migration->up(); } }