feat(seats): assignable license seats — buy N, assign/reassign/reclaim per user

A seat-based product (meta.seat_based) sold at quantity N provisions N
assignable LicenseSeat rows instead of granting the buyer. Assigning a seat
runs the product's existing ProductAction grants against the seat holder via
an explicit `grantee` (resolution: grantee ?? purchaser ?? subscription->user),
so a seat confers exactly the product's access with zero duplicated grant logic.

- LicenseSeat model: assign / reassign / reclaim / retire / refreshGrant
- SeatService: idempotent provisioning for one-time purchases and subscriptions
  (sizes the pool to quantity; renewal extends expiry + re-grants held seats)
- SeatAssigned / SeatReassigned / SeatRevoked events
- Product::isSeatBased(); ProductPurchase + Subscription provision on
  completion / lifecycle hooks
- license_seats migration + factory; config `shop.seats` block
- 15 new tests (purchase, subscription, renewal, retire, delegation, gating)

Backward compatible: with no `grantee` the buyer grant is unchanged, and seat
behavior is gated behind the per-product meta flag + config `shop.seats.enabled`.
Full package suite green: 1425 tests / 3812 assertions / 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-07-29 10:07:58 +02:00
parent 7d35c224f3
commit b154d8fea2
15 changed files with 1158 additions and 0 deletions

View File

@ -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,

View File

@ -0,0 +1,33 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Enums\SeatStatus;
use Blax\Shop\Models\LicenseSeat;
use Blax\Shop\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
class LicenseSeatFactory extends Factory
{
protected $model = LicenseSeat::class;
public function definition(): array
{
return [
'product_id' => 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(),
]);
}
}

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable(config('shop.tables.license_seats', 'license_seats'))) {
return;
}
Schema::create(config('shop.tables.license_seats', 'license_seats'), function (Blueprint $table) {
$table->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'));
}
};

31
src/Enums/SeatStatus.php Normal file
View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Enums;
/**
* Lifecycle of a single {@see \Blax\Shop\Models\LicenseSeat}.
*
* - UNASSIGNED: a free seat in the pool, ready to be handed to a user.
* - ASSIGNED: currently held by a user, who has the product's grants.
* - REVOKED: the seat itself was retired (pool shrank / subscription
* canceled) and can no longer be assigned. Distinct from
* *reclaiming* a seat, which returns it to UNASSIGNED so it can
* be handed to someone else.
*/
enum SeatStatus: string
{
case UNASSIGNED = 'unassigned';
case ASSIGNED = 'assigned';
case REVOKED = 'revoked';
public function label(): string
{
return match ($this) {
self::UNASSIGNED => 'Unassigned',
self::ASSIGNED => 'Assigned',
self::REVOKED => 'Revoked',
};
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\LicenseSeat;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a previously free {@see LicenseSeat} is handed to a user.
* The product's grant actions have already been fired against the assignee by
* the time this event runs.
*/
class SeatAssigned
{
use Dispatchable, SerializesModels;
public function __construct(public LicenseSeat $seat) {}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\LicenseSeat;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when an already-held {@see LicenseSeat} is moved from one user to
* another in a single operation. The previous holder's grants have been revoked
* and the new holder's granted by the time this event runs.
*/
class SeatReassigned
{
use Dispatchable, SerializesModels;
public function __construct(
public LicenseSeat $seat,
public ?Model $previousAssignee = null,
) {}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Events;
use Blax\Shop\Models\LicenseSeat;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Dispatched when a {@see LicenseSeat} is reclaimed (returned to the pool) or
* retired (removed from it). The former holder's grants have been revoked by
* the time this event runs; `$previousAssignee` is who lost access (may be null
* if the seat was already free).
*/
class SeatRevoked
{
use Dispatchable, SerializesModels;
public function __construct(
public LicenseSeat $seat,
public ?Model $previousAssignee = null,
) {}
}

318
src/Models/LicenseSeat.php Normal file
View File

@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Models;
use Blax\Shop\Enums\SeatStatus;
use Blax\Shop\Events\SeatAssigned;
use Blax\Shop\Events\SeatReassigned;
use Blax\Shop\Events\SeatRevoked;
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\MorphTo;
/**
* One assignable license seat produced by a seat-based purchase or subscription
* of `quantity = N` (which yields N seats). A seat can be handed to any user,
* moved to a different user at any time, or reclaimed and each transition
* runs the *product's own* {@see ProductAction} grants against the seat's
* current holder rather than the buyer. That is the whole point: an assigned
* seat confers exactly the access the product would confer if the holder had
* bought it personally, with zero duplicated grant logic.
*
* Grants flow through the same engine as a normal purchase
* ({@see Product::callActions()}), but with an explicit `grantee` in the action
* context. Host action jobs resolve the target as
* `grantee ?? purchaser ?? subscription->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<Product, $this>
*/
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<ProductPurchase, $this>
*/
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<Subscription, $this>
*/
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<Model, $this>
*/
public function assignee(): MorphTo
{
return $this->morphTo('assignee');
}
/** @param Builder<self> $query */
public function scopeUnassigned(Builder $query): Builder
{
return $query->where('status', SeatStatus::UNASSIGNED->value);
}
/** @param Builder<self> $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<self> $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();
}
}

View File

@ -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.

View File

@ -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<LicenseSeat, $this>
*/
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);
}
}

View File

@ -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<LicenseSeat, $this>
*/
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);
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Services;
use Blax\Shop\Enums\SeatStatus;
use Blax\Shop\Models\LicenseSeat;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Subscription;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Turns a seat-based purchase/subscription of `quantity = N` into N assignable
* {@see LicenseSeat} rows, and keeps that pool in sync across the billing
* lifecycle. All operations are idempotent safe to re-run on duplicate
* webhooks or repeated lifecycle hooks.
*
* Assignment / reassignment / reclaim live on the {@see LicenseSeat} model
* itself; this service only owns pool provisioning and renewal sync.
*/
class SeatService
{
/**
* Ensure a one-time purchase has exactly `quantity` seats. Seats from a
* plain purchase have no expiry.
*
* @return Collection<int, LicenseSeat>
*/
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<int, LicenseSeat>
*/
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<string, string> $source
* @return Collection<int, LicenseSeat>
*/
protected function ensureSeats(string $productId, array $source, int $target, ?Carbon $expiresAt): Collection
{
/** @var class-string<LicenseSeat> $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;
}
}

View File

@ -0,0 +1,287 @@
<?php
namespace Blax\Shop\Tests\Feature\Seats;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Enums\SeatStatus;
use Blax\Shop\Events\SeatAssigned;
use Blax\Shop\Events\SeatReassigned;
use Blax\Shop\Events\SeatRevoked;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAction;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Illuminate\Support\Facades\Event;
use Workbench\App\Models\User;
/**
* Test double: records the grantee + event each time the product's seat grant
* actions fire, so a test can prove an assigned seat confers access to the
* *holder* and revokes it from the *previous* holder reusing the exact same
* ProductAction engine a normal purchase uses.
*/
class SeatGrantSpy
{
/** @var array<int, array{event: ?string, grantee_id: ?string, seat_id: ?string}> */
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<int, string> 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());
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace Blax\Shop\Tests\Feature\Seats;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\SeatStatus;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAction;
use Blax\Shop\Models\Subscription;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
class SeatSubscriptionTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
SubSeatGrantSpy::$calls = [];
}
private function seatProduct(bool $seatBased = true): Product
{
$product = Product::create([
'name' => '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<int, array<string, mixed>> */
public static array $calls = [];
public static function handle(...$args): void
{
self::$calls[] = $args;
}
}

View File

@ -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();
}
}