diff --git a/README.md b/README.md index 952046c..24bf6a6 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ # Laravel Shop [![Tests](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml/badge.svg)](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml) -[![Tests Count](https://img.shields.io/badge/tests-1404%20passing-success?style=flat-square)](#testing) -[![Assertions](https://img.shields.io/badge/assertions-3759-blue?style=flat-square)](#testing) +[![Tests Count](https://img.shields.io/badge/tests-1409%20passing-success?style=flat-square)](#testing) +[![Assertions](https://img.shields.io/badge/assertions-3772-blue?style=flat-square)](#testing) [![Latest Version](https://img.shields.io/packagist/v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![License](https://img.shields.io/packagist/l/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![PHP Version](https://img.shields.io/packagist/php-v/blax-software/laravel-shop.svg?style=flat-square)](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. ``` -Tests: 1404, Assertions: 3759 +Tests: 1409, Assertions: 3772 ``` CI runs the full suite on every push (see the badge above). To run it diff --git a/config/shop.php b/config/shop.php index ef7e805..a42b969 100644 --- a/config/shop.php +++ b/config/shop.php @@ -38,6 +38,8 @@ return [ 'product_price_tiers' => 'product_price_tiers', 'products' => 'products', 'cart_discounts' => 'cart_discounts', + 'subscriptions' => 'subscriptions', + 'subscription_items' => 'subscription_items', ], // Model classes (allow overriding in main instance) @@ -55,6 +57,23 @@ return [ 'order_note' => \Blax\Shop\Models\OrderNote::class, 'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::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 diff --git a/database/migrations/2025_01_01_000004_create_blax_shop_subscriptions.php b/database/migrations/2025_01_01_000004_create_blax_shop_subscriptions.php new file mode 100644 index 0000000..f554754 --- /dev/null +++ b/database/migrations/2025_01_01_000004_create_blax_shop_subscriptions.php @@ -0,0 +1,73 @@ +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')); + } +}; diff --git a/docs/03-purchasing.md b/docs/03-purchasing.md index aee8371..404fed8 100644 --- a/docs/03-purchasing.md +++ b/docs/03-purchasing.md @@ -770,3 +770,40 @@ In addition, any `ProductAction` configured on the product with the `purchased` event still runs automatically on completion — the product is resolved via `config('shop.models.product')` / `...product_price`, so apps 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). diff --git a/src/Events/SubscriptionCanceled.php b/src/Events/SubscriptionCanceled.php new file mode 100644 index 0000000..5df45e3 --- /dev/null +++ b/src/Events/SubscriptionCanceled.php @@ -0,0 +1,23 @@ + $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 + */ + 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 + */ + 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); + } +} diff --git a/src/Models/SubscriptionItem.php b/src/Models/SubscriptionItem.php new file mode 100644 index 0000000..4e2d97a --- /dev/null +++ b/src/Models/SubscriptionItem.php @@ -0,0 +1,33 @@ + + */ + public function subscription(): BelongsTo + { + return $this->belongsTo( + config('shop.models.subscription', Subscription::class) + ); + } +} diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php index e8aa66c..3a14cfd 100644 --- a/src/ShopServiceProvider.php +++ b/src/ShopServiceProvider.php @@ -34,6 +34,8 @@ class ShopServiceProvider extends ServiceProvider $this->registerMigrations(); + $this->registerSubscriptionModels(); + $this->registerRouteMacros(); // Load routes if enabled (API only) @@ -97,6 +99,32 @@ class ShopServiceProvider extends ServiceProvider $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 * concisely. Currently provides: diff --git a/tests/Feature/Subscriptions/SubscriptionLifecycleTest.php b/tests/Feature/Subscriptions/SubscriptionLifecycleTest.php new file mode 100644 index 0000000..e34596a --- /dev/null +++ b/tests/Feature/Subscriptions/SubscriptionLifecycleTest.php @@ -0,0 +1,152 @@ + '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> */ + public static array $calls = []; + + public static function handle(...$args): void + { + self::$calls[] = $args; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 5eb2c33..33ed3e1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -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->up(); + + $migration = include __DIR__ . '/../database/migrations/2025_01_01_000004_create_blax_shop_subscriptions.php'; + $migration->up(); } }