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,19 +2305,68 @@ 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
|
* Create a Stripe Checkout Session for this cart
|
||||||
*
|
*
|
||||||
* This method:
|
* This method:
|
||||||
* - Validates the cart (doesn't convert it)
|
* - Validates the cart (doesn't convert it)
|
||||||
* - Creates ProductPurchase records for each cart item (with PENDING status)
|
* - 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
|
* - Creates line items with descriptions including booking dates
|
||||||
* - Returns the Stripe checkout session
|
* - Returns the Stripe checkout session
|
||||||
*
|
*
|
||||||
* @param array $options Optional session parameters (success_url, cancel_url, etc.)
|
* @param array $options Optional session parameters (success_url, cancel_url, etc.)
|
||||||
* @param string|null $url Optional fullPath URL for success and cancel URLs
|
* @param string|null $url Optional fullPath URL for success and cancel URLs
|
||||||
*
|
*
|
||||||
* @return mixed Stripe\Checkout\Session instance
|
* @return mixed Stripe\Checkout\Session instance
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*/
|
*/
|
||||||
|
|
@ -2368,9 +2417,14 @@ class Cart extends Model
|
||||||
});
|
});
|
||||||
|
|
||||||
$lineItems = [];
|
$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) {
|
foreach ($this->items as $item) {
|
||||||
$product = $item->purchasable;
|
$product = $item->purchasable;
|
||||||
|
$priceModel = $item->price()->first();
|
||||||
|
|
||||||
// Get product name (use short_description if available, otherwise name)
|
// Get product name (use short_description if available, otherwise name)
|
||||||
$productName = $product->name ?? 'Product [' . $product->id . ']';
|
$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,
|
// the line being charged), then the cart's own currency column,
|
||||||
// then the package default — never assume the cart row has one.
|
// then the package default — never assume the cart row has one.
|
||||||
$lineCurrency = strtolower(
|
$lineCurrency = strtolower(
|
||||||
$item->price()->first()?->currency
|
$priceModel?->currency
|
||||||
?? $this->currency
|
?? $this->currency
|
||||||
?? config('shop.currency', 'usd')
|
?? 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
|
// Build line item using price_data for dynamic pricing
|
||||||
$lineItem = [
|
$lineItem = [
|
||||||
'price_data' => [
|
'price_data' => [
|
||||||
|
|
@ -2412,6 +2503,15 @@ class Cart extends Model
|
||||||
$lineItems[] = $lineItem;
|
$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');
|
$success_url = $url ?? $options['success_url'] ?? route('shop.stripe.success');
|
||||||
$cancel_url = $url ?? $options['cancel_url'] ?? route('shop.stripe.cancel');
|
$cancel_url = $url ?? $options['cancel_url'] ?? route('shop.stripe.cancel');
|
||||||
|
|
||||||
|
|
@ -2428,7 +2528,7 @@ class Cart extends Model
|
||||||
'payment_method_types' => ['card'],
|
'payment_method_types' => ['card'],
|
||||||
'currency' => strtolower($this->currency ?? config('shop.currency', 'usd')),
|
'currency' => strtolower($this->currency ?? config('shop.currency', 'usd')),
|
||||||
'line_items' => $lineItems,
|
'line_items' => $lineItems,
|
||||||
'mode' => 'payment',
|
'mode' => $mode,
|
||||||
'success_url' => $success_url,
|
'success_url' => $success_url,
|
||||||
'cancel_url' => $cancel_url,
|
'cancel_url' => $cancel_url,
|
||||||
'client_reference_id' => $this->id,
|
'client_reference_id' => $this->id,
|
||||||
|
|
@ -2437,6 +2537,17 @@ class Cart extends Model
|
||||||
], $options['metadata'] ?? []),
|
], $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
|
// Add customer email if available
|
||||||
if ($this->customer) {
|
if ($this->customer) {
|
||||||
if (method_exists($this->customer, 'email')) {
|
if (method_exists($this->customer, 'email')) {
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,208 @@ class CartCheckoutSessionTest extends TestCase
|
||||||
$this->assertSame('eur', $captured['line_items'][0]['price_data']['currency']);
|
$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
|
* Mock Stripe Checkout Session creation to avoid actual API calls
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue