feat(subscriptions): Cashier-backed subscription lifecycle with product link + events
Adds the package's missing subscription lifecycle so any host app gets duration-aware, product-linked subscriptions without re-implementing billing: - Models: Subscription (extends Laravel\Cashier\Subscription) + SubscriptionItem, in the package's UUID convention, resolved through shop.models/shop.tables. Subscription gains product()/resolveProduct(), callProductActions() (runs the product's ProductActions with the subscription + an access-expiry override), and recordStarted()/recordRenewed()/recordCanceled() lifecycle hooks. - Events: SubscriptionStarted / SubscriptionRenewed / SubscriptionCanceled, carrying the Cashier subscription so host subclasses work too. - Migration: UUID subscriptions / subscription_items tables (hasTable-guarded, config table names, nullable product_id + current_period_* columns). - ShopServiceProvider points Cashier at these models by default; opt out via shop.subscriptions.register_cashier_models for apps that subclass Cashier. Additive and backward-compatible (registration is config-gated, tables are guarded). Adds SubscriptionLifecycleTest; full suite 1409 green. Docs + README.
This commit is contained in:
parent
96b9a19287
commit
d4ae9339ac
|
|
@ -3,8 +3,8 @@
|
||||||
# Laravel Shop
|
# Laravel Shop
|
||||||
|
|
||||||
[](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml)
|
[](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml)
|
||||||
[](#testing)
|
[](#testing)
|
||||||
[](#testing)
|
[](#testing)
|
||||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
[](https://packagist.org/packages/blax-software/laravel-shop)
|
||||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
[](https://packagist.org/packages/blax-software/laravel-shop)
|
||||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
[](https://packagist.org/packages/blax-software/laravel-shop)
|
||||||
|
|
@ -192,7 +192,7 @@ booking, Stripe sync and the event surface — so host applications can lean
|
||||||
on the behaviour with confidence.
|
on the behaviour with confidence.
|
||||||
|
|
||||||
```
|
```
|
||||||
Tests: 1404, Assertions: 3759
|
Tests: 1409, Assertions: 3772
|
||||||
```
|
```
|
||||||
|
|
||||||
CI runs the full suite on every push (see the badge above). To run it
|
CI runs the full suite on every push (see the badge above). To run it
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ return [
|
||||||
'product_price_tiers' => 'product_price_tiers',
|
'product_price_tiers' => 'product_price_tiers',
|
||||||
'products' => 'products',
|
'products' => 'products',
|
||||||
'cart_discounts' => 'cart_discounts',
|
'cart_discounts' => 'cart_discounts',
|
||||||
|
'subscriptions' => 'subscriptions',
|
||||||
|
'subscription_items' => 'subscription_items',
|
||||||
],
|
],
|
||||||
|
|
||||||
// Model classes (allow overriding in main instance)
|
// Model classes (allow overriding in main instance)
|
||||||
|
|
@ -55,6 +57,23 @@ return [
|
||||||
'order_note' => \Blax\Shop\Models\OrderNote::class,
|
'order_note' => \Blax\Shop\Models\OrderNote::class,
|
||||||
'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class,
|
'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class,
|
||||||
'payment_method' => \Blax\Shop\Models\PaymentMethod::class,
|
'payment_method' => \Blax\Shop\Models\PaymentMethod::class,
|
||||||
|
'subscription' => \Blax\Shop\Models\Subscription::class,
|
||||||
|
'subscription_item' => \Blax\Shop\Models\SubscriptionItem::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Subscriptions are Cashier-backed. The package binds its own
|
||||||
|
* Cashier-extending Subscription / SubscriptionItem models (above) so it
|
||||||
|
* can link a subscription to a product and run product actions on the
|
||||||
|
* billing lifecycle. Set `subscriptions.register_cashier_models` to false
|
||||||
|
* if the host app wants to point Cashier at its own models instead.
|
||||||
|
*/
|
||||||
|
'subscriptions' => [
|
||||||
|
'register_cashier_models' => env('SHOP_REGISTER_CASHIER_MODELS', true),
|
||||||
|
// Stripe interval => the product-action event fired on a NEW subscription.
|
||||||
|
'started_event' => 'subscription.started',
|
||||||
|
'renewed_event' => 'subscription.renewed',
|
||||||
|
'canceled_event' => 'subscription.canceled',
|
||||||
],
|
],
|
||||||
|
|
||||||
// API Routes configuration
|
// API Routes configuration
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cashier-backed subscription tables, in the package's UUID convention.
|
||||||
|
*
|
||||||
|
* Mirrors Laravel Cashier's `subscriptions` / `subscription_items` schema (so
|
||||||
|
* the package's Cashier-extending models work out of the box) but with UUID
|
||||||
|
* primary keys to match the rest of the package, plus a nullable `product_id`
|
||||||
|
* so a subscription can be linked to the {@see \Blax\Shop\Models\Product} it
|
||||||
|
* sells (used to run product actions on the billing lifecycle), and the
|
||||||
|
* `current_period_*` columns Cashier 15 syncs from Stripe.
|
||||||
|
*
|
||||||
|
* Guarded with `hasTable`, so an app that already owns a `subscriptions` table
|
||||||
|
* (e.g. one published from Cashier) is left untouched — point
|
||||||
|
* `shop.tables.subscriptions` at a different name if you need both.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$subscriptions = config('shop.tables.subscriptions', 'subscriptions');
|
||||||
|
$subscriptionItems = config('shop.tables.subscription_items', 'subscription_items');
|
||||||
|
|
||||||
|
if (! Schema::hasTable($subscriptions)) {
|
||||||
|
Schema::create($subscriptions, function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->uuid('user_id')->index();
|
||||||
|
$table->uuid('product_id')->nullable()->index();
|
||||||
|
$table->string('type');
|
||||||
|
$table->string('stripe_id')->unique();
|
||||||
|
$table->string('stripe_status');
|
||||||
|
$table->string('stripe_price')->nullable();
|
||||||
|
$table->integer('quantity')->nullable();
|
||||||
|
$table->timestamp('trial_ends_at')->nullable();
|
||||||
|
$table->timestamp('ends_at')->nullable();
|
||||||
|
$table->timestamp('current_period_start')->nullable();
|
||||||
|
$table->timestamp('current_period_end')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'stripe_status']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Schema::hasTable($subscriptionItems)) {
|
||||||
|
Schema::create($subscriptionItems, function (Blueprint $table) use ($subscriptions) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->uuid('subscription_id');
|
||||||
|
$table->string('stripe_id')->unique();
|
||||||
|
$table->string('stripe_product');
|
||||||
|
$table->string('stripe_price');
|
||||||
|
$table->integer('quantity')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['subscription_id', 'stripe_price']);
|
||||||
|
$table->foreign('subscription_id')
|
||||||
|
->references('id')->on($subscriptions)
|
||||||
|
->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists(config('shop.tables.subscription_items', 'subscription_items'));
|
||||||
|
Schema::dropIfExists(config('shop.tables.subscriptions', 'subscriptions'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -770,3 +770,40 @@ In addition, any `ProductAction` configured on the product with the
|
||||||
`purchased` event still runs automatically on completion — the product is
|
`purchased` event still runs automatically on completion — the product is
|
||||||
resolved via `config('shop.models.product')` / `...product_price`, so apps
|
resolved via `config('shop.models.product')` / `...product_price`, so apps
|
||||||
overriding those models are covered too.
|
overriding those models are covered too.
|
||||||
|
|
||||||
|
## Subscriptions
|
||||||
|
|
||||||
|
Subscriptions are **Cashier-backed**. The package ships
|
||||||
|
`Blax\Shop\Models\Subscription` (extends `Laravel\Cashier\Subscription`) which
|
||||||
|
adds the commerce link and lifecycle hooks Cashier doesn't have:
|
||||||
|
|
||||||
|
- `product()` / `resolveProduct()` — the `Product` this subscription sells
|
||||||
|
(linked via `product_id`, or resolved from the first item's `stripe_product`).
|
||||||
|
- `callProductActions($expiresAtOverride, $event)` — run the product's
|
||||||
|
`ProductAction`s for a lifecycle event, passing the subscription and an
|
||||||
|
optional access-expiry so grants can be scoped to the billing cycle.
|
||||||
|
- `recordStarted()` / `recordRenewed()` / `recordCanceled()` — fire the
|
||||||
|
`SubscriptionStarted` / `SubscriptionRenewed` / `SubscriptionCanceled` events
|
||||||
|
(and, for started/renewed, run the product actions).
|
||||||
|
|
||||||
|
The package points Cashier at these models automatically. If your app already
|
||||||
|
subclasses Cashier's `Subscription`, set
|
||||||
|
`shop.subscriptions.register_cashier_models = false` and register your own
|
||||||
|
models; everything in the package resolves through `shop.models.*` /
|
||||||
|
`shop.tables.*`, so your models slot in.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Blax\Shop\Events\SubscriptionRenewed;
|
||||||
|
|
||||||
|
class ExtendAccessOnRenewal
|
||||||
|
{
|
||||||
|
public function handle(SubscriptionRenewed $event): void
|
||||||
|
{
|
||||||
|
// $event->subscription->product, ->current_period_end, …
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The subscription tables use the package's UUID convention; the migration is
|
||||||
|
`hasTable`-guarded, so point `shop.tables.subscriptions` elsewhere if your app
|
||||||
|
already owns a `subscriptions` table (e.g. a bigint Cashier one).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Laravel\Cashier\Subscription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched when a subscription is canceled. Access typically lapses at period end via Cashier's grace handling rather than being revoked immediately.
|
||||||
|
*
|
||||||
|
* Carries the Cashier subscription (the package's {@see \Blax\Shop\Models\Subscription}
|
||||||
|
* is a Cashier subscription, so this works for host subclasses too). Listen
|
||||||
|
* here to drive fulfillment without coupling to billing internals.
|
||||||
|
*/
|
||||||
|
class SubscriptionCanceled
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public Subscription $subscription) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Laravel\Cashier\Subscription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched when a subscription renews into a new billing cycle (recurring invoice paid). Extend access to the new period here.
|
||||||
|
*
|
||||||
|
* Carries the Cashier subscription (the package's {@see \Blax\Shop\Models\Subscription}
|
||||||
|
* is a Cashier subscription, so this works for host subclasses too). Listen
|
||||||
|
* here to drive fulfillment without coupling to billing internals.
|
||||||
|
*/
|
||||||
|
class SubscriptionRenewed
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public Subscription $subscription) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Laravel\Cashier\Subscription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched when a new subscription becomes active (initial checkout / first invoice). Grant access for the first billing cycle here.
|
||||||
|
*
|
||||||
|
* Carries the Cashier subscription (the package's {@see \Blax\Shop\Models\Subscription}
|
||||||
|
* is a Cashier subscription, so this works for host subclasses too). Listen
|
||||||
|
* here to drive fulfillment without coupling to billing internals.
|
||||||
|
*/
|
||||||
|
class SubscriptionStarted
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public Subscription $subscription) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
|
use Blax\Shop\Events\SubscriptionCanceled;
|
||||||
|
use Blax\Shop\Events\SubscriptionRenewed;
|
||||||
|
use Blax\Shop\Events\SubscriptionStarted;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Laravel\Cashier\Subscription as CashierSubscription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cashier-backed subscription, in the package's UUID convention, linked to the
|
||||||
|
* {@see Product} it sells so the billing lifecycle can drive product actions
|
||||||
|
* and fulfillment events.
|
||||||
|
*
|
||||||
|
* This is the package's missing "subscription lifecycle": Cashier owns the
|
||||||
|
* billing mechanics (status, trials, grace, proration, Stripe sync); this
|
||||||
|
* subclass adds the commerce link — `product()` — and the lifecycle hooks
|
||||||
|
* ({@see recordStarted()}, {@see recordRenewed()}, {@see recordCanceled()})
|
||||||
|
* that fire package events and run the product's {@see ProductAction}s with a
|
||||||
|
* subscription + expiry context, so any host app gets duration-aware grants
|
||||||
|
* for free.
|
||||||
|
*
|
||||||
|
* Host apps that already subclass Cashier's Subscription can point
|
||||||
|
* `shop.models.subscription` at their model (and set
|
||||||
|
* `shop.subscriptions.register_cashier_models = false`) — everything here is
|
||||||
|
* resolved through config, never hard-coded class references.
|
||||||
|
*
|
||||||
|
* @property string|null $product_id
|
||||||
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, SubscriptionItem> $items
|
||||||
|
*/
|
||||||
|
class Subscription extends CashierSubscription
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
public function getTable()
|
||||||
|
{
|
||||||
|
return config('shop.tables.subscriptions', 'subscriptions');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The product this subscription sells (cached on `product_id`).
|
||||||
|
*
|
||||||
|
* @return BelongsTo<Model, $this>
|
||||||
|
*/
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(config('shop.models.product', Product::class), 'product_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription line items — uses the configured item model.
|
||||||
|
*
|
||||||
|
* @return HasMany<SubscriptionItem, $this>
|
||||||
|
*/
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(
|
||||||
|
config('shop.models.subscription_item', SubscriptionItem::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve (and cache) the product this subscription sells: the linked
|
||||||
|
* `product_id` first, else the first item's `stripe_product` mapped to a
|
||||||
|
* Product via `stripe_product_id`.
|
||||||
|
*/
|
||||||
|
public function resolveProduct(): ?Model
|
||||||
|
{
|
||||||
|
$productModel = config('shop.models.product', Product::class);
|
||||||
|
|
||||||
|
if ($this->product_id) {
|
||||||
|
$product = $productModel::find($this->product_id);
|
||||||
|
if ($product) {
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripeProduct = $this->items()->first()?->stripe_product;
|
||||||
|
if ($stripeProduct) {
|
||||||
|
$product = $productModel::where('stripe_product_id', $stripeProduct)->first();
|
||||||
|
if ($product && ! $this->product_id) {
|
||||||
|
$this->forceFill(['product_id' => $product->getKey()])->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the linked product's actions for a subscription lifecycle event,
|
||||||
|
* passing the subscription and an optional access-expiry override so
|
||||||
|
* grants can be scoped to the billing cycle.
|
||||||
|
*/
|
||||||
|
public function callProductActions(
|
||||||
|
?\Carbon\Carbon $expiresAtOverride = null,
|
||||||
|
?string $event = null
|
||||||
|
): void {
|
||||||
|
$product = $this->resolveProduct();
|
||||||
|
if (! $product || ! method_exists($product, 'callActions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event ??= config('shop.subscriptions.started_event', 'subscription.started');
|
||||||
|
|
||||||
|
$product->callActions($event, null, [
|
||||||
|
'subscription' => $this,
|
||||||
|
'expiresAtOverride' => $expiresAtOverride,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a new subscription as started: fire {@see SubscriptionStarted} and
|
||||||
|
* run the product's actions for the configured "started" event.
|
||||||
|
*/
|
||||||
|
public function recordStarted(?\Carbon\Carbon $expiresAtOverride = null): void
|
||||||
|
{
|
||||||
|
$this->callProductActions($expiresAtOverride, config('shop.subscriptions.started_event', 'subscription.started'));
|
||||||
|
SubscriptionStarted::dispatch($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a renewal (new billing cycle): fire {@see SubscriptionRenewed} and
|
||||||
|
* re-run the product's actions so grants extend to the new period.
|
||||||
|
*/
|
||||||
|
public function recordRenewed(?\Carbon\Carbon $expiresAtOverride = null): void
|
||||||
|
{
|
||||||
|
$this->callProductActions($expiresAtOverride, config('shop.subscriptions.renewed_event', 'subscription.renewed'));
|
||||||
|
SubscriptionRenewed::dispatch($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a cancellation: fire {@see SubscriptionCanceled}. Access is left to
|
||||||
|
* lapse at period end (Cashier grace handling) rather than revoked here.
|
||||||
|
*/
|
||||||
|
public function recordCanceled(): void
|
||||||
|
{
|
||||||
|
SubscriptionCanceled::dispatch($this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Laravel\Cashier\SubscriptionItem as CashierSubscriptionItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cashier-backed subscription line item in the package's UUID convention.
|
||||||
|
* Bound to the configured {@see Subscription} model.
|
||||||
|
*/
|
||||||
|
class SubscriptionItem extends CashierSubscriptionItem
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
public function getTable()
|
||||||
|
{
|
||||||
|
return config('shop.tables.subscription_items', 'subscription_items');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Subscription, $this>
|
||||||
|
*/
|
||||||
|
public function subscription(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(
|
||||||
|
config('shop.models.subscription', Subscription::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,8 @@ class ShopServiceProvider extends ServiceProvider
|
||||||
|
|
||||||
$this->registerMigrations();
|
$this->registerMigrations();
|
||||||
|
|
||||||
|
$this->registerSubscriptionModels();
|
||||||
|
|
||||||
$this->registerRouteMacros();
|
$this->registerRouteMacros();
|
||||||
|
|
||||||
// Load routes if enabled (API only)
|
// Load routes if enabled (API only)
|
||||||
|
|
@ -97,6 +99,32 @@ class ShopServiceProvider extends ServiceProvider
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point Laravel Cashier at the package's product-linked Subscription /
|
||||||
|
* SubscriptionItem models so subscriptions created by Cashier carry the
|
||||||
|
* commerce link + lifecycle hooks. Host apps that subclass Cashier
|
||||||
|
* themselves disable this via `shop.subscriptions.register_cashier_models`
|
||||||
|
* and register their own models (which boot after this provider, so they
|
||||||
|
* win). No-op if Cashier isn't installed.
|
||||||
|
*/
|
||||||
|
protected function registerSubscriptionModels(): void
|
||||||
|
{
|
||||||
|
if (! config('shop.subscriptions.register_cashier_models', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! class_exists(\Laravel\Cashier\Cashier::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
\Laravel\Cashier\Cashier::useSubscriptionModel(
|
||||||
|
config('shop.models.subscription', \Blax\Shop\Models\Subscription::class)
|
||||||
|
);
|
||||||
|
\Laravel\Cashier\Cashier::useSubscriptionItemModel(
|
||||||
|
config('shop.models.subscription_item', \Blax\Shop\Models\SubscriptionItem::class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register Route macros that hosts can use to wire shop endpoints
|
* Register Route macros that hosts can use to wire shop endpoints
|
||||||
* concisely. Currently provides:
|
* concisely. Currently provides:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\Subscriptions;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductStatus;
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Events\SubscriptionCanceled;
|
||||||
|
use Blax\Shop\Events\SubscriptionRenewed;
|
||||||
|
use Blax\Shop\Events\SubscriptionStarted;
|
||||||
|
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 Illuminate\Support\Facades\Event;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
class SubscriptionLifecycleTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
RecordingSubscriptionAction::$calls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function product(): Product
|
||||||
|
{
|
||||||
|
return Product::create([
|
||||||
|
'name' => 'Subscription Product',
|
||||||
|
'sku' => 'SUB-'.uniqid(),
|
||||||
|
'type' => ProductType::SIMPLE,
|
||||||
|
'status' => ProductStatus::PUBLISHED,
|
||||||
|
'manage_stock' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actionFor(Product $product, string $event = 'subscription.started'): void
|
||||||
|
{
|
||||||
|
ProductAction::create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'events' => [$event],
|
||||||
|
'class' => RecordingSubscriptionAction::class,
|
||||||
|
'method' => 'handle',
|
||||||
|
'defer' => false,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subscriptionFor(Product $product, User $user): 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' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function the_product_relation_and_resolver_link_subscription_to_product(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$product = $this->product();
|
||||||
|
$sub = $this->subscriptionFor($product, $user);
|
||||||
|
|
||||||
|
$this->assertTrue($sub->product->is($product));
|
||||||
|
$this->assertTrue($sub->resolveProduct()->is($product));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function call_product_actions_runs_actions_with_subscription_context(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$product = $this->product();
|
||||||
|
$this->actionFor($product, 'subscription.started');
|
||||||
|
|
||||||
|
$sub = $this->subscriptionFor($product, $user);
|
||||||
|
$sub->callProductActions();
|
||||||
|
|
||||||
|
$this->assertCount(1, RecordingSubscriptionAction::$calls);
|
||||||
|
$args = RecordingSubscriptionAction::$calls[0];
|
||||||
|
$this->assertSame('subscription.started', $args['event']);
|
||||||
|
$this->assertInstanceOf(Subscription::class, $args['subscription']);
|
||||||
|
$this->assertTrue($args['subscription']->is($sub));
|
||||||
|
$this->assertArrayHasKey('expiresAtOverride', $args);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function record_started_fires_event_and_runs_actions(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$product = $this->product();
|
||||||
|
$this->actionFor($product, 'subscription.started');
|
||||||
|
$sub = $this->subscriptionFor($product, $user);
|
||||||
|
|
||||||
|
Event::fake([SubscriptionStarted::class]);
|
||||||
|
$sub->recordStarted();
|
||||||
|
|
||||||
|
$this->assertCount(1, RecordingSubscriptionAction::$calls);
|
||||||
|
Event::assertDispatched(SubscriptionStarted::class, fn (SubscriptionStarted $e) => $e->subscription->is($sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function record_renewed_fires_event_and_runs_renewal_actions(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$product = $this->product();
|
||||||
|
$this->actionFor($product, 'subscription.renewed');
|
||||||
|
$sub = $this->subscriptionFor($product, $user);
|
||||||
|
|
||||||
|
Event::fake([SubscriptionRenewed::class]);
|
||||||
|
$sub->recordRenewed();
|
||||||
|
|
||||||
|
$this->assertCount(1, RecordingSubscriptionAction::$calls);
|
||||||
|
$this->assertSame('subscription.renewed', RecordingSubscriptionAction::$calls[0]['event']);
|
||||||
|
Event::assertDispatched(SubscriptionRenewed::class, fn (SubscriptionRenewed $e) => $e->subscription->is($sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function record_canceled_fires_event(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$product = $this->product();
|
||||||
|
$sub = $this->subscriptionFor($product, $user);
|
||||||
|
|
||||||
|
Event::fake([SubscriptionCanceled::class]);
|
||||||
|
$sub->recordCanceled();
|
||||||
|
|
||||||
|
Event::assertDispatched(SubscriptionCanceled::class, fn (SubscriptionCanceled $e) => $e->subscription->is($sub));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test fulfillment handler — records each invocation's (named) arguments so the
|
||||||
|
* test can assert the subscription context was passed through.
|
||||||
|
*/
|
||||||
|
class RecordingSubscriptionAction
|
||||||
|
{
|
||||||
|
/** @var array<int, array<string, mixed>> */
|
||||||
|
public static array $calls = [];
|
||||||
|
|
||||||
|
public static function handle(...$args): void
|
||||||
|
{
|
||||||
|
self::$calls[] = $args;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,5 +70,8 @@ abstract class TestCase extends Orchestra
|
||||||
|
|
||||||
$migration = include __DIR__ . '/../database/migrations/2026_01_01_000002_add_max_per_cart_and_max_per_user_to_products.php';
|
$migration = include __DIR__ . '/../database/migrations/2026_01_01_000002_add_max_per_cart_and_max_per_user_to_products.php';
|
||||||
$migration->up();
|
$migration->up();
|
||||||
|
|
||||||
|
$migration = include __DIR__ . '/../database/migrations/2025_01_01_000004_create_blax_shop_subscriptions.php';
|
||||||
|
$migration->up();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue