laravel-shop/tests/Feature/Checkout/CartCheckoutSessionTest.php

682 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Blax\Shop\Tests\Feature\Checkout;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
use PHPUnit\Framework\Attributes\Test;
class CartCheckoutSessionTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Cart $cart;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
}
#[Test]
public function it_throws_exception_when_stripe_is_disabled()
{
config(['shop.stripe.enabled' => false]);
$product = Product::factory()->create(['manage_stock' => false]);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 1000,
'currency' => 'USD',
'is_default' => true,
]);
$this->cart->addToCart($product, 1);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Stripe is not enabled');
$this->cart->checkoutSession();
}
#[Test]
public function it_builds_checkout_session_with_simple_product_without_stripe_api()
{
// Enable Stripe but don't actually call the API
config(['shop.stripe.enabled' => true]);
config(['shop.currency' => 'usd']);
config(['services.stripe.secret' => 'sk_test_fake']);
$product = Product::factory()->create([
'name' => 'Test Product',
'short_description' => 'Short desc',
'manage_stock' => false,
]);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 1500, // $15.00
'currency' => 'USD',
'is_default' => true,
]);
$this->cart->addToCart($product, 2);
// Mock the Stripe API to avoid actual calls
$this->mockStripeCheckoutSession();
$session = $this->cart->checkoutSession([
'success_url' => 'https://example.com/success',
'cancel_url' => 'https://example.com/cancel',
]);
// Verify the session was created with correct parameters
$this->assertNotNull($session);
$this->assertEquals('mock_session_id', $session->id);
}
#[Test]
public function it_includes_booking_dates_in_product_name()
{
config(['shop.stripe.enabled' => true]);
config(['services.stripe.secret' => 'sk_test_fake']);
$bookingProduct = Product::factory()->create([
'name' => 'Hotel Room',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$bookingProduct->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $bookingProduct->id,
'purchasable_type' => Product::class,
'unit_amount' => 10000, // $100 per day
'currency' => 'USD',
'is_default' => true,
]);
$from = now()->addDays(1)->startOfDay();
$until = now()->addDays(3)->startOfDay(); // 2 days
$this->cart->addToCart($bookingProduct, 1, [], $from, $until);
// Capture the session params
$sessionParams = null;
\Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) {
$sessionParams = $params;
$mockSession = new \stdClass();
$mockSession->id = 'mock_session_id';
return $mockSession;
};
$this->cart->checkoutSession([
'success_url' => 'https://example.com/success',
'cancel_url' => 'https://example.com/cancel',
]);
$data = $sessionParams['line_items'][0]['price_data']['product_data'];
$this->assertStringContainsString('Hotel Room', $data['name']);
$this->assertStringContainsString('from', $data['description']);
$this->assertStringContainsString('to', $data['description']);
}
#[Test]
public function it_calculates_correct_unit_amount_in_cents()
{
config(['shop.stripe.enabled' => true]);
config(['services.stripe.secret' => 'sk_test_fake']);
$product = Product::factory()->create(['name' => 'Test Product', 'manage_stock' => false]);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 2550, // $25.50
'currency' => 'USD',
'is_default' => true,
]);
$this->cart->addToCart($product, 1);
// Capture the session params
$sessionParams = null;
\Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) {
$sessionParams = $params;
$mockSession = new \stdClass();
$mockSession->id = 'mock_session_id';
return $mockSession;
};
$this->cart->checkoutSession([
'success_url' => 'https://example.com/success',
'cancel_url' => 'https://example.com/cancel',
]);
// Price is stored in cents (2550), Stripe expects cents (2550)
$this->assertEquals(2550, $sessionParams['line_items'][0]['price_data']['unit_amount']);
}
#[Test]
public function it_handles_booking_with_fractional_days()
{
config(['shop.stripe.enabled' => true]);
config(['services.stripe.secret' => 'sk_test_fake']);
$bookingProduct = Product::factory()->create([
'name' => 'Parking Spot',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$bookingProduct->increaseStock(10);
ProductPrice::factory()->create([
'purchasable_id' => $bookingProduct->id,
'purchasable_type' => Product::class,
'unit_amount' => 1000, // $10 per day
'currency' => 'USD',
'is_default' => true,
]);
// 4 hours booking (0.1667 days)
$from = now()->addDays(1)->setTime(10, 0);
$until = now()->addDays(1)->setTime(14, 0);
$this->cart->addToCart($bookingProduct, 1, [], $from, $until);
// Capture the session params
$sessionParams = null;
\Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) {
$sessionParams = $params;
$mockSession = new \stdClass();
$mockSession->id = 'mock_session_id';
return $mockSession;
};
$this->cart->checkoutSession([
'success_url' => 'https://example.com/success',
'cancel_url' => 'https://example.com/cancel',
]);
// The cart item should have calculated the fractional day price
$cartItem = $this->cart->items->first();
// Price is already in cents, no conversion needed
$this->assertEquals($cartItem->price, $sessionParams['line_items'][0]['price_data']['unit_amount']);
}
#[Test]
public function it_creates_separate_line_items_for_multiple_products()
{
config(['shop.stripe.enabled' => true]);
config(['services.stripe.secret' => 'sk_test_fake']);
$product1 = Product::factory()->create(['name' => 'Product 1', 'manage_stock' => false]);
$product2 = Product::factory()->create(['name' => 'Product 2', 'manage_stock' => false]);
ProductPrice::factory()->create([
'purchasable_id' => $product1->id,
'purchasable_type' => Product::class,
'unit_amount' => 1000,
'currency' => 'USD',
'is_default' => true,
'type' => 'one_time',
]);
ProductPrice::factory()->create([
'purchasable_id' => $product2->id,
'purchasable_type' => Product::class,
'unit_amount' => 2000,
'currency' => 'USD',
'is_default' => true,
'type' => 'one_time',
]);
$this->cart->addToCart($product1, 2);
$this->cart->addToCart($product2, 1);
// Capture the session params
$sessionParams = null;
\Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) {
$sessionParams = $params;
$mockSession = new \stdClass();
$mockSession->id = 'mock_session_id';
return $mockSession;
};
$this->cart->checkoutSession([
'success_url' => 'https://example.com/success',
'cancel_url' => 'https://example.com/cancel',
]);
$this->assertCount(2, $sessionParams['line_items']);
$this->assertEquals(2, $sessionParams['line_items'][0]['quantity']);
$this->assertEquals(1, $sessionParams['line_items'][1]['quantity']);
}
#[Test]
public function it_uses_configured_currency()
{
config(['shop.stripe.enabled' => true]);
config(['shop.currency' => 'eur']);
config(['services.stripe.secret' => 'sk_test_fake']);
$product = Product::factory()->create(['name' => 'Product', 'manage_stock' => false]);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 1000,
'currency' => 'EUR',
'is_default' => true,
]);
$this->cart->addToCart($product, 1);
// Capture the session params
$sessionParams = null;
\Stripe\Checkout\Session::$createCallback = function ($params) use (&$sessionParams) {
$sessionParams = $params;
$mockSession = new \stdClass();
$mockSession->id = 'mock_session_id';
return $mockSession;
};
$this->cart->checkoutSession([
'success_url' => 'https://example.com/success',
'cancel_url' => 'https://example.com/cancel',
]);
$this->assertEquals('eur', $sessionParams['line_items'][0]['price_data']['currency']);
}
#[Test]
public function it_stores_session_id_in_cart_meta()
{
config(['shop.stripe.enabled' => true]);
config(['services.stripe.secret' => 'sk_test_fake']);
$product = Product::factory()->create(['name' => 'Product', 'manage_stock' => false]);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 1000,
'currency' => 'USD',
'is_default' => true,
]);
$this->cart->addToCart($product, 1);
$this->mockStripeCheckoutSession();
$this->cart->checkoutSession([
'success_url' => 'https://example.com/success',
'cancel_url' => 'https://example.com/cancel',
]);
$this->cart->refresh();
$meta = $this->cart->meta;
$this->assertNotNull($meta->stripe_session_id ?? null);
$this->assertEquals('mock_session_id', $meta->stripe_session_id);
}
#[Test]
public function checkout_session_emits_lowercase_currency_codes(): void
{
// Regression: Cart::checkoutSession() used to call
// `strtoupper($this->currency)`, but Stripe expects lowercase ISO
// codes — host apps were sending 'USD' / 'EUR' to Stripe by mistake.
// The implementation now consistently lowercases the resolved
// currency at both the session level and per line item.
config(['shop.stripe.enabled' => true]);
config(['shop.currency' => 'eur']);
config(['services.stripe.secret' => 'sk_test_fake']);
$product = Product::factory()->create(['name' => 'P', 'manage_stock' => false]);
ProductPrice::factory()->create([
'purchasable_id' => $product->id,
'purchasable_type' => Product::class,
'unit_amount' => 1000,
'currency' => 'EUR', // intentionally uppercase on the price model
'is_default' => true,
]);
$this->cart->addToCart($product, 1);
$captured = null;
\Stripe\Checkout\Session::$createCallback = function ($params) use (&$captured) {
$captured = $params;
$session = new \stdClass();
$session->id = 'mock';
return $session;
};
$this->cart->checkoutSession([
'success_url' => 'https://example.com/s',
'cancel_url' => 'https://example.com/c',
]);
// Session-level: lowercase regardless of how the cart row spells it.
$this->assertSame('eur', $captured['currency']);
// Line-item: derives from the price model's currency and lowercases it.
$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',
]);
}
/** The first line item name of every session checkoutSession() creates. */
private array $lineNames = [];
private function recordLineNames(): void
{
$this->lineNames = [];
\Stripe\Checkout\Session::$createCallback = function ($params) {
$this->lineNames[] = $params['line_items'][0]['price_data']['product_data']['name'];
$mock = new \stdClass;
$mock->id = 'mock_session_id';
return $mock;
};
}
#[Test]
public function line_item_names_the_chosen_price_only_when_enabled()
{
config(['shop.stripe.enabled' => true, 'services.stripe.secret' => 'sk_test_fake']);
$product = Product::factory()->create(['name' => 'Flat Art', 'manage_stock' => false]);
ProductPrice::factory()->create([
'purchasable_id' => $product->id, 'purchasable_type' => Product::class,
'unit_amount' => 5000, 'currency' => 'EUR', 'is_default' => true, 'name' => 'A5',
]);
$a4 = ProductPrice::factory()->create([
'purchasable_id' => $product->id, 'purchasable_type' => Product::class,
'unit_amount' => 7000, 'currency' => 'EUR', 'is_default' => false, 'name' => 'A4',
]);
$this->cart->addToCart($a4, 1);
$this->recordLineNames();
$this->cart->checkoutSession(['success_url' => 'https://example.com/s', 'cancel_url' => 'https://example.com/c']);
config(['shop.stripe.line_item_price_name' => true]);
$this->cart->checkoutSession(['success_url' => 'https://example.com/s', 'cancel_url' => 'https://example.com/c']);
$this->assertSame(['Flat Art', 'Flat Art – A4'], $this->lineNames);
}
#[Test]
public function line_item_skips_the_price_name_of_a_product_with_one_price()
{
config([
'shop.stripe.enabled' => true,
'services.stripe.secret' => 'sk_test_fake',
'shop.stripe.line_item_price_name' => true,
]);
$product = Product::factory()->create(['name' => 'Shinsou Standee', 'manage_stock' => false]);
ProductPrice::factory()->create([
'purchasable_id' => $product->id, 'purchasable_type' => Product::class,
'unit_amount' => 800, 'currency' => 'EUR', 'is_default' => true, 'name' => 'Default',
]);
$this->cart->addToCart($product, 1);
$this->recordLineNames();
$this->cart->checkoutSession(['success_url' => 'https://example.com/s', 'cancel_url' => 'https://example.com/c']);
$this->assertSame(['Shinsou Standee'], $this->lineNames);
}
/**
* Mock Stripe Checkout Session creation to avoid actual API calls
*/
protected function mockStripeCheckoutSession()
{
// Create a simple mock that returns a session object
\Stripe\Checkout\Session::$createCallback = function ($params) {
$mockSession = new \stdClass();
$mockSession->id = 'mock_session_id';
$mockSession->url = 'https://checkout.stripe.com/mock';
return $mockSession;
};
}
}
// Add a simple mock capability to Stripe Session class for testing
namespace Stripe\Checkout;
class Session
{
public static $createCallback = null;
public static function create($params)
{
if (self::$createCallback) {
return call_user_func(self::$createCallback, $params);
}
// If no callback, throw exception (actual Stripe call would be made)
throw new \Exception('Stripe API call attempted without mock. Set createCallback first.');
}
public static function resetMock()
{
self::$createCallback = null;
}
}