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.
This commit is contained in:
Fabian @ Blax Software 2026-06-09 19:29:14 +02:00
parent 02fd5640b4
commit 8594af4236
2 changed files with 121 additions and 12 deletions

View File

@ -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<int, array{product: Model, item: ?Model}>
*/
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,
]);
}
}
/**

View File

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