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.
This commit is contained in:
Fabian @ Blax Software 2026-06-02 09:34:32 +02:00
parent 4712133eac
commit d4008caec0
3 changed files with 341 additions and 6 deletions

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
use Exception;
/**
* Thrown when a single cart mixes recurring (subscription) and one-time
* prices. Stripe Checkout sessions are single-mode `payment` OR
* `subscription` so such a cart cannot be checked out in one session and
* must be split (or the offending lines removed) by the host application.
*/
class MixedCheckoutModeException extends Exception
{
public function __construct(
string $message = "Cannot mix recurring and one-time prices in a single checkout session."
) {
parent::__construct($message);
}
}

View File

@ -2305,13 +2305,62 @@ class Cart extends Model
});
}
/**
* Resolve the Stripe `recurring` descriptor for a price, or null when the
* price is one-time. Tolerates both the package's enum-cast model and a
* host-app price model that stores `type`/`interval` as plain strings, so
* it keeps working when `shop.models.product_price` is overridden.
*
* Stripe has no native "quarter" interval, so a quarterly cadence is
* expressed as a 3-month interval.
*
* @param mixed $price A ProductPrice (package or host) or null.
* @return array{interval: string, interval_count: int}|null
*/
protected function stripeRecurringFor($price): ?array
{
if (!$price) {
return null;
}
$type = $price->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
*
@ -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')) {

View File

@ -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
*/