From d4008caec0f8a8e65655f5e206b0e39a7ff33a8e Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 2 Jun 2026 09:34:32 +0200 Subject: [PATCH] feat(checkout): support subscription/recurring carts in Stripe Checkout Cart::checkoutSession() now inspects each line's price type and selects the session mode automatically: `subscription` when the cart carries any recurring price, `payment` otherwise. Recurring lines reuse a synced Stripe Price (stripe_price_id) when present and fall back to dynamic price_data with a `recurring` block otherwise; quarterly cadence maps to a 3-month interval since Stripe has no native quarter. The cart id is propagated via subscription_data metadata for webhook mapping. Mixing recurring and one-time prices in one cart throws the new MixedCheckoutModeException, since a Stripe Checkout session is single-mode. The recurring resolver tolerates both the package's enum-cast price model and a host model storing type/interval as plain strings, so it keeps working when shop.models.product_price is overridden. --- src/Exceptions/MixedCheckoutModeException.php | 22 ++ src/Models/Cart.php | 123 ++++++++++- .../Checkout/CartCheckoutSessionTest.php | 202 ++++++++++++++++++ 3 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 src/Exceptions/MixedCheckoutModeException.php diff --git a/src/Exceptions/MixedCheckoutModeException.php b/src/Exceptions/MixedCheckoutModeException.php new file mode 100644 index 0000000..b903a56 --- /dev/null +++ b/src/Exceptions/MixedCheckoutModeException.php @@ -0,0 +1,22 @@ +type instanceof \Blax\Shop\Enums\PriceType + ? $price->type->value + : (string) $price->type; + + if ($type !== \Blax\Shop\Enums\PriceType::RECURRING->value) { + return null; + } + + $interval = $price->interval instanceof \Blax\Shop\Enums\RecurringInterval + ? $price->interval->value + : (is_string($price->interval) ? $price->interval : null); + + if ($interval === null || $interval === '') { + return null; + } + + $count = (int) ($price->interval_count ?? 1); + if ($count < 1) { + $count = 1; + } + + if ($interval === \Blax\Shop\Enums\RecurringInterval::QUARTER->value) { + return ['interval' => 'month', 'interval_count' => 3 * $count]; + } + + return ['interval' => $interval, 'interval_count' => $count]; + } + /** * Create a Stripe Checkout Session for this cart - * + * * This method: * - Validates the cart (doesn't convert it) * - Creates ProductPurchase records for each cart item (with PENDING status) - * - Uses dynamic price_data for each cart item (no pre-created Stripe prices needed) + * - Uses a synced Stripe Price for recurring lines (and dynamic price_data + * otherwise), so subscription and one-off carts both check out + * - Picks `subscription` mode when the cart carries any recurring price, + * `payment` mode otherwise; mixing the two throws MixedCheckoutModeException * - Creates line items with descriptions including booking dates * - Returns the Stripe checkout session - * + * * @param array $options Optional session parameters (success_url, cancel_url, etc.) * @param string|null $url Optional fullPath URL for success and cancel URLs - * + * * @return mixed Stripe\Checkout\Session instance * @throws \Exception */ @@ -2368,9 +2417,14 @@ class Cart extends Model }); $lineItems = []; + // Track the kinds of pricing present so we can pick the session mode. + // Stripe sessions are single-mode, so a cart may not mix the two. + $hasRecurring = false; + $hasOneTime = false; foreach ($this->items as $item) { $product = $item->purchasable; + $priceModel = $item->price()->first(); // Get product name (use short_description if available, otherwise name) $productName = $product->name ?? 'Product [' . $product->id . ']'; @@ -2391,11 +2445,48 @@ class Cart extends Model // the line being charged), then the cart's own currency column, // then the package default — never assume the cart row has one. $lineCurrency = strtolower( - $item->price()->first()?->currency + $priceModel?->currency ?? $this->currency ?? config('shop.currency', 'usd') ); + // Recurring? Resolve the Stripe `recurring` descriptor for this line + // (null for one-time prices). Quarterly has no native Stripe interval, + // so it's expressed as a 3-month cadence. + $recurring = $this->stripeRecurringFor($priceModel); + + if ($recurring !== null) { + $hasRecurring = true; + + // Prefer a synced Stripe Price for recurring lines — it's the + // canonical record Stripe already knows (name, tax behaviour, + // metadata). Fall back to dynamic price_data + recurring block + // when no stripe_price_id is on file. + if (!empty($priceModel?->stripe_price_id)) { + $lineItems[] = [ + 'price' => $priceModel->stripe_price_id, + 'quantity' => $item->quantity, + ]; + continue; + } + + $lineItems[] = [ + 'price_data' => [ + 'currency' => $lineCurrency, + 'product_data' => [ + 'name' => $productName, + ...($description ? ['description' => $description] : []), + ], + 'unit_amount' => $unitAmountCents, + 'recurring' => $recurring, + ], + 'quantity' => $item->quantity, + ]; + continue; + } + + $hasOneTime = true; + // Build line item using price_data for dynamic pricing $lineItem = [ 'price_data' => [ @@ -2412,6 +2503,15 @@ class Cart extends Model $lineItems[] = $lineItem; } + // A cart can't be both a subscription and a one-off in one Stripe + // session — surface that explicitly rather than letting Stripe reject + // it with an opaque error. + if ($hasRecurring && $hasOneTime) { + throw new \Blax\Shop\Exceptions\MixedCheckoutModeException(); + } + + $mode = $hasRecurring ? 'subscription' : 'payment'; + $success_url = $url ?? $options['success_url'] ?? route('shop.stripe.success'); $cancel_url = $url ?? $options['cancel_url'] ?? route('shop.stripe.cancel'); @@ -2428,7 +2528,7 @@ class Cart extends Model 'payment_method_types' => ['card'], 'currency' => strtolower($this->currency ?? config('shop.currency', 'usd')), 'line_items' => $lineItems, - 'mode' => 'payment', + 'mode' => $mode, 'success_url' => $success_url, 'cancel_url' => $cancel_url, 'client_reference_id' => $this->id, @@ -2437,6 +2537,17 @@ class Cart extends Model ], $options['metadata'] ?? []), ]; + // Propagate the cart id onto the subscription itself so invoice/ + // subscription webhooks can map back to this cart, not just the + // originating checkout session. + if ($mode === 'subscription') { + $sessionParams['subscription_data'] = [ + 'metadata' => array_merge([ + 'cart_id' => $this->id, + ], $options['subscription_metadata'] ?? []), + ]; + } + // Add customer email if available if ($this->customer) { if (method_exists($this->customer, 'email')) { diff --git a/tests/Feature/Checkout/CartCheckoutSessionTest.php b/tests/Feature/Checkout/CartCheckoutSessionTest.php index 72ec2fc..cdb7f17 100644 --- a/tests/Feature/Checkout/CartCheckoutSessionTest.php +++ b/tests/Feature/Checkout/CartCheckoutSessionTest.php @@ -375,6 +375,208 @@ class CartCheckoutSessionTest extends TestCase $this->assertSame('eur', $captured['line_items'][0]['price_data']['currency']); } + #[Test] + public function it_uses_subscription_mode_and_synced_price_for_recurring_lines() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create(['name' => 'Sub', 'manage_stock' => false]); + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1990, + 'currency' => 'EUR', + 'is_default' => true, + 'type' => 'recurring', + 'interval' => 'month', + 'interval_count' => 1, + 'stripe_price_id' => 'price_sub_123', + ]); + $this->cart->addToCart($product, 1); + + $captured = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$captured) { + $captured = $params; + $s = new \stdClass(); + $s->id = 'mock'; + return $s; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/s', + 'cancel_url' => 'https://example.com/c', + ]); + + $this->assertSame('subscription', $captured['mode']); + // Recurring line with a synced Stripe Price uses `price`, not price_data. + $this->assertSame('price_sub_123', $captured['line_items'][0]['price']); + $this->assertArrayNotHasKey('price_data', $captured['line_items'][0]); + // The cart id rides along on the subscription for webhook mapping. + $this->assertSame($this->cart->id, $captured['subscription_data']['metadata']['cart_id']); + } + + #[Test] + public function it_builds_recurring_price_data_when_no_stripe_price_id() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create(['name' => 'Sub2', 'manage_stock' => false]); + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2500, + 'currency' => 'EUR', + 'is_default' => true, + 'type' => 'recurring', + 'interval' => 'year', + 'interval_count' => 1, + 'stripe_price_id' => null, + ]); + $this->cart->addToCart($product, 1); + + $captured = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$captured) { + $captured = $params; + $s = new \stdClass(); + $s->id = 'mock'; + return $s; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/s', + 'cancel_url' => 'https://example.com/c', + ]); + + $this->assertSame('subscription', $captured['mode']); + $recurring = $captured['line_items'][0]['price_data']['recurring']; + $this->assertSame('year', $recurring['interval']); + $this->assertSame(1, $recurring['interval_count']); + $this->assertSame(2500, $captured['line_items'][0]['price_data']['unit_amount']); + } + + #[Test] + public function it_maps_quarter_interval_to_three_months() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create(['name' => 'Q', 'manage_stock' => false]); + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 9000, + 'currency' => 'EUR', + 'is_default' => true, + 'type' => 'recurring', + 'interval' => 'quarter', + 'interval_count' => 1, + 'stripe_price_id' => null, + ]); + $this->cart->addToCart($product, 1); + + $captured = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$captured) { + $captured = $params; + $s = new \stdClass(); + $s->id = 'mock'; + return $s; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/s', + 'cancel_url' => 'https://example.com/c', + ]); + + $recurring = $captured['line_items'][0]['price_data']['recurring']; + $this->assertSame('month', $recurring['interval']); + $this->assertSame(3, $recurring['interval_count']); + } + + #[Test] + public function it_uses_payment_mode_for_one_time_prices() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $product = Product::factory()->create(['name' => 'OT', 'manage_stock' => false]); + ProductPrice::factory()->create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 500, + 'currency' => 'EUR', + 'is_default' => true, + 'type' => 'one_time', + 'interval' => null, + 'interval_count' => null, + ]); + $this->cart->addToCart($product, 1); + + $captured = null; + \Stripe\Checkout\Session::$createCallback = function ($params) use (&$captured) { + $captured = $params; + $s = new \stdClass(); + $s->id = 'mock'; + return $s; + }; + + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/s', + 'cancel_url' => 'https://example.com/c', + ]); + + $this->assertSame('payment', $captured['mode']); + $this->assertArrayNotHasKey('recurring', $captured['line_items'][0]['price_data']); + $this->assertArrayNotHasKey('subscription_data', $captured); + } + + #[Test] + public function it_throws_when_mixing_recurring_and_one_time_prices() + { + config(['shop.stripe.enabled' => true]); + config(['services.stripe.secret' => 'sk_test_fake']); + + $sub = Product::factory()->create(['name' => 'S', 'manage_stock' => false]); + ProductPrice::factory()->create([ + 'purchasable_id' => $sub->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 1000, + 'currency' => 'EUR', + 'is_default' => true, + 'type' => 'recurring', + 'interval' => 'month', + 'interval_count' => 1, + ]); + + $one = Product::factory()->create(['name' => 'O', 'manage_stock' => false]); + ProductPrice::factory()->create([ + 'purchasable_id' => $one->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 2000, + 'currency' => 'EUR', + 'is_default' => true, + 'type' => 'one_time', + 'interval' => null, + 'interval_count' => null, + ]); + + $this->cart->addToCart($sub, 1); + $this->cart->addToCart($one, 1); + + \Stripe\Checkout\Session::$createCallback = function ($params) { + $s = new \stdClass(); + $s->id = 'mock'; + return $s; + }; + + $this->expectException(\Blax\Shop\Exceptions\MixedCheckoutModeException::class); + $this->cart->checkoutSession([ + 'success_url' => 'https://example.com/s', + 'cancel_url' => 'https://example.com/c', + ]); + } + /** * Mock Stripe Checkout Session creation to avoid actual API calls */