From 8594af4236cd6c701be60bd83f012032a92b931f Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 9 Jun 2026 19:29:14 +0200 Subject: [PATCH] fix(subscriptions): grant every product in a multi-item (bundle) subscription callProductActions() now resolves and runs actions for ALL subscription line items (new resolveProducts()), not just items()->first(), so combined/configurator bundle subscriptions fulfill every product instead of silently granting only the first. resolveProduct() is kept for single-product callers; product_id is still cached to the first resolved product. Adds a multi-item bundle regression test to SubscriptionLifecycleTest. --- src/Models/Subscription.php | 76 ++++++++++++++++--- .../SubscriptionLifecycleTest.php | 57 ++++++++++++++ 2 files changed, 121 insertions(+), 12 deletions(-) diff --git a/src/Models/Subscription.php b/src/Models/Subscription.php index 40af131..ff5f684 100644 --- a/src/Models/Subscription.php +++ b/src/Models/Subscription.php @@ -95,25 +95,77 @@ class Subscription extends CashierSubscription } /** - * 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. + * Resolve EVERY product this subscription sells — one entry per line item — + * so multi-product (bundle) subscriptions fulfill ALL of them, not just the + * first. Each entry is ['product' => Model, 'item' => SubscriptionItem]. + * Products are de-duplicated by key. Falls back to the single linked + * `product_id` when no item maps to a product, and caches the first + * resolved product on `product_id` for single-product callers. + * + * @return \Illuminate\Support\Collection + */ + public function resolveProducts(): \Illuminate\Support\Collection + { + $productModel = config('shop.models.product', Product::class); + $resolved = collect(); + $seen = []; + + foreach ($this->items as $item) { + $stripeProduct = $item->stripe_product ?? null; + if (! $stripeProduct) { + continue; + } + $product = $productModel::where('stripe_product_id', $stripeProduct)->first(); + if (! $product) { + continue; + } + $key = (string) $product->getKey(); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $resolved->push(['product' => $product, 'item' => $item]); + } + + if ($resolved->isEmpty() && $this->product_id) { + $product = $productModel::find($this->product_id); + if ($product) { + $resolved->push(['product' => $product, 'item' => $this->items->first()]); + } + } + + // Back-compat: cache the first resolved product on product_id. + if (! $this->product_id && $resolved->isNotEmpty()) { + $this->forceFill(['product_id' => $resolved->first()['product']->getKey()])->saveQuietly(); + } + + return $resolved; + } + + /** + * Run EVERY sold product's actions for a subscription lifecycle event, + * passing the subscription, the originating line item, and an optional + * access-expiry override so grants can be scoped to the billing cycle. + * Multi-product (bundle) subscriptions now grant all line items. */ 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, - ]); + foreach ($this->resolveProducts() as $entry) { + $product = $entry['product'] ?? null; + if (! $product || ! method_exists($product, 'callActions')) { + continue; + } + + $product->callActions($event, null, [ + 'subscription' => $this, + 'subscriptionItem' => $entry['item'] ?? null, + 'expiresAtOverride' => $expiresAtOverride, + ]); + } } /** diff --git a/tests/Feature/Subscriptions/SubscriptionLifecycleTest.php b/tests/Feature/Subscriptions/SubscriptionLifecycleTest.php index e34596a..5af9cdf 100644 --- a/tests/Feature/Subscriptions/SubscriptionLifecycleTest.php +++ b/tests/Feature/Subscriptions/SubscriptionLifecycleTest.php @@ -10,6 +10,7 @@ use Blax\Shop\Events\SubscriptionStarted; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductAction; use Blax\Shop\Models\Subscription; +use Blax\Shop\Models\SubscriptionItem; use Blax\Shop\Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; @@ -49,6 +50,29 @@ class SubscriptionLifecycleTest extends TestCase ]); } + private function productWithStripe(string $tag, string $stripeProductId): Product + { + return Product::create([ + 'name' => 'Bundle Product '.$tag, + 'sku' => 'SUB-'.$tag.'-'.uniqid(), + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'manage_stock' => false, + 'stripe_product_id' => $stripeProductId, + ]); + } + + private function addItem(Subscription $sub, string $stripeProduct, string $stripePrice): void + { + SubscriptionItem::create([ + 'subscription_id' => $sub->id, + 'stripe_id' => 'si_'.uniqid(), + 'stripe_product' => $stripeProduct, + 'stripe_price' => $stripePrice, + 'quantity' => 1, + ]); + } + private function subscriptionFor(Product $product, User $user): Subscription { return Subscription::create([ @@ -91,6 +115,39 @@ class SubscriptionLifecycleTest extends TestCase $this->assertArrayHasKey('expiresAtOverride', $args); } + #[Test] + public function call_product_actions_grants_every_product_in_a_bundle_subscription(): void + { + // A combined / multi-product subscription (e.g. a "configurator" bundle) + // has one line item per product. Every product's actions must fire — not + // just the first item's. This is the regression guard for bundle grants. + $user = User::factory()->create(); + $a = $this->productWithStripe('A', 'prod_A'); + $b = $this->productWithStripe('B', 'prod_B'); + $this->actionFor($a, 'subscription.started'); + $this->actionFor($b, 'subscription.started'); + + $sub = Subscription::create([ + 'user_id' => $user->id, + // intentionally NO product_id — resolution must come from the items + 'type' => 'default', + 'stripe_id' => 'sub_'.uniqid(), + 'stripe_status' => 'active', + 'quantity' => 1, + ]); + $this->addItem($sub, 'prod_A', 'price_a'); + $this->addItem($sub, 'prod_B', 'price_b'); + $sub->load('items'); + + $sub->callProductActions(); + + $this->assertCount(2, RecordingSubscriptionAction::$calls, 'Both bundle products should be fulfilled, not just the first item.'); + $fulfilled = collect(RecordingSubscriptionAction::$calls) + ->map(fn ($c) => $c['subscriptionItem']?->stripe_product) + ->sort()->values()->all(); + $this->assertSame(['prod_A', 'prod_B'], $fulfilled); + } + #[Test] public function record_started_fires_event_and_runs_actions(): void {