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:
parent
4712133eac
commit
d4008caec0
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue