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:
parent
02fd5640b4
commit
8594af4236
|
|
@ -95,26 +95,78 @@ 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');
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a new subscription as started: fire {@see SubscriptionStarted} and
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue