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:
parent
7d35c224f3
commit
b154d8fea2
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue