619 lines
21 KiB
PHP
619 lines
21 KiB
PHP
<?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',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|