A stripe & BFI cart

This commit is contained in:
a6a2f5842 2025-11-28 10:24:07 +01:00
parent 929e87bc28
commit ffc8716c22
19 changed files with 296 additions and 36 deletions

5
.gitignore vendored
View File

@ -5,4 +5,7 @@ composer.lock
.idea/ .idea/
workbench workbench
.phpunit.result.cache .phpunit.result.cache
.phpunit.cache .phpunit.cache
.env
phpunit.xml
*dist*

View File

@ -93,7 +93,7 @@ $purchase = $user->purchase($product, quantity: 2, options: [
$cartItem = $user->addToCart($product, quantity: 1); $cartItem = $user->addToCart($product, quantity: 1);
// Checkout cart // Checkout cart
$completedPurchases = $user->checkout(); $completedPurchases = $user->checkoutCart();
// Check if user has purchased // Check if user has purchased
if ($user->hasPurchased($product)) { if ($user->hasPurchased($product)) {

View File

@ -18,11 +18,11 @@
} }
}, },
"require": { "require": {
"php": ">=8.0", "php": "^8.2|^8.3",
"illuminate/support": "^10.0|^11.0|^12.0", "illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/database": "^10.0|^11.0|^12.0", "illuminate/database": "^10.0|^11.0|^12.0",
"blax-software/laravel-workkit": "dev-master|*", "blax-software/laravel-workkit": "dev-master|*",
"stripe/stripe-php": "^19.0" "laravel/cashier": "^15.7"
}, },
"require-dev": { "require-dev": {
"orchestra/testbench": "^8.0|^9.0", "orchestra/testbench": "^8.0|^9.0",
@ -55,6 +55,11 @@
] ]
} }
}, },
"config": {
"platform": {
"php": "8.2.28"
}
},
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true "prefer-stable": true
} }

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasTable(config('shop.tables.users', 'users'))) {
Schema::table(config('shop.tables.users', 'users'), function (Blueprint $table) {
if (!Schema::hasColumn(config('shop.tables.users', 'users'), 'stripe_id')) {
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
}
});
}else {
throw new \Exception('Users table does not exist. Please run the initial migrations first.');
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasTable(config('shop.tables.users', 'users'))) {
Schema::table(config('shop.tables.users', 'users'), function (Blueprint $table) {
if (Schema::hasColumn(config('shop.tables.users', 'users'), 'stripe_id')) {
$table->dropIndex([
'stripe_id',
]);
$table->dropColumn([
'stripe_id',
'pm_type',
'pm_last_four',
'trial_ends_at',
]);
}
});
}
}
};

View File

@ -266,6 +266,7 @@ return new class extends Migration
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->uuid('cart_id'); $table->uuid('cart_id');
$table->uuidMorphs('purchasable'); $table->uuidMorphs('purchasable');
$table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete();
$table->integer('quantity')->default(1); $table->integer('quantity')->default(1);
$table->decimal('price', 10, 2)->default(0); $table->decimal('price', 10, 2)->default(0);
$table->decimal('regular_price', 10, 2)->nullable(); $table->decimal('regular_price', 10, 2)->nullable();

View File

@ -364,7 +364,7 @@ Route::get('/checkout/success', function (Request $request) {
$user = auth()->user(); $user = auth()->user();
// Convert cart to purchases // Convert cart to purchases
$purchases = $user->checkout(); $purchases = $user->checkoutCart();
// Store charge ID // Store charge ID
foreach ($purchases as $purchase) { foreach ($purchases as $purchase) {

View File

@ -191,7 +191,7 @@ $stats = [
```php ```php
try { try {
$purchases = $user->checkout(); $purchases = $user->checkoutCart();
// Checkout successful // Checkout successful
// Cart items are now converted to completed purchases // Cart items are now converted to completed purchases
@ -491,7 +491,7 @@ Route::post('/checkout', function () {
$user = auth()->user(); $user = auth()->user();
try { try {
$purchases = $user->checkout(); $purchases = $user->checkoutCart();
return redirect()->route('orders.success') return redirect()->route('orders.success')
->with('success', 'Order placed successfully!'); ->with('success', 'Order placed successfully!');

View File

@ -29,5 +29,7 @@
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/> <env name="QUEUE_DRIVER" value="sync"/>
<env name="SHOP_CACHE_ENABLED" value="false"/> <env name="SHOP_CACHE_ENABLED" value="false"/>
<env name="STRIPE_KEY" value="your_stripe_test_key_here"/>
<env name="STRIPE_SECRET" value="your_stripe_test_secret_here"/>
</php> </php>
</phpunit> </phpunit>

View File

@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
class Cart extends Model class Cart extends Model
@ -63,7 +64,7 @@ class Cart extends Model
public function getTotal(): float public function getTotal(): float
{ {
return $this->items->sum(function ($item) { return $this->items->sum(function ($item) {
return $item->quantity * $item->price; return $item->subtotal;
}); });
} }
@ -126,9 +127,9 @@ class Cart extends Model
'purchasable_id' => $cartable->getKey(), 'purchasable_id' => $cartable->getKey(),
'purchasable_type' => get_class($cartable), 'purchasable_type' => get_class($cartable),
'quantity' => $quantity, 'quantity' => $quantity,
'price' => ($cartable?->sale_unit_amount ?? $cartable?->unit_amount ?? 0), 'price' => $cartable?->getCurrentPrice(),
'regular_price' => $cartable?->unit_amount, 'regular_price' => $cartable?->getCurrentPrice(false) ?? $cartable?->unit_amount,
'subtotal' => ($cartable?->sale_unit_amount ?? $cartable?->unit_amount ?? 0) * $quantity, 'subtotal' => ($cartable?->getCurrentPrice()) * $quantity,
'parameters' => $parameters, 'parameters' => $parameters,
]); ]);

View File

@ -20,6 +20,7 @@ class CartItem extends Model
'regular_price', 'regular_price',
'subtotal', 'subtotal',
'parameters', 'parameters',
'purchase_id',
'meta', 'meta',
]; ];
@ -66,6 +67,15 @@ class CartItem extends Model
return $this->morphTo('purchasable'); return $this->morphTo('purchasable');
} }
public function purchase()
{
return $this->hasOne(
config('shop.models.product_purchase', ProductPurchase::class),
'id',
'purchase_id'
);
}
public function product(): BelongsTo|null public function product(): BelongsTo|null
{ {
if ($this->purchasable_type === config('shop.models.product', Product::class)) { if ($this->purchasable_type === config('shop.models.product', Product::class)) {

View File

@ -212,9 +212,9 @@ class Product extends Model implements Purchasable, Cartable
return true; return true;
} }
public function getCurrentPrice(): ?float public function getCurrentPrice(bool|null $sales_price = null): ?float
{ {
return $this->defaultPrice()->first()?->getCurrentPrice($this->isOnSale()); return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
} }
public function isInStock(): bool public function isInStock(): bool

View File

@ -51,7 +51,7 @@ class ProductPrice extends Model implements Cartable
return $query->where('active', true); return $query->where('active', true);
} }
public function getCurrentPrice($sale_price): float public function getCurrentPrice(bool|null $sale_price = null): float
{ {
if ($sale_price) { if ($sale_price) {
return $this->sale_unit_amount; return $this->sale_unit_amount;

View File

@ -25,6 +25,7 @@ class ShopServiceProvider extends ServiceProvider
// Publish migrations // Publish migrations
$this->publishes([ $this->publishes([
__DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub' => $this->getMigrationFileName('create_blax_shop_tables.php'), __DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub' => $this->getMigrationFileName('create_blax_shop_tables.php'),
__DIR__ . '/../database/migrations/add_stripe_to_users_table.php.stub' => $this->getMigrationFileName('add_stripe_to_users_table.php'),
], 'shop-migrations'); ], 'shop-migrations');
// Load routes if enabled (API only) // Load routes if enabled (API only)

View File

@ -34,7 +34,7 @@ trait HasCart
* *
* @return Cart * @return Cart
*/ */
public function currentCart() public function currentCart() : \Blax\Shop\Models\Cart
{ {
return $this->cart() return $this->cart()
->whereNull('converted_at') ->whereNull('converted_at')

View File

@ -5,6 +5,7 @@ namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\MultiplePurchaseOptions; use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\NotPurchasable; use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem; use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
@ -136,10 +137,10 @@ trait HasShoppingCapabilities
* *
* @param string|null $cartId (deprecated - not used) * @param string|null $cartId (deprecated - not used)
* @param array $options * @param array $options
* @return Collection * @return Cart
* @throws \Exception * @throws \Exception
*/ */
public function checkout(?string $cartId = null, array $options = []): Collection public function checkoutCart(?string $cartId = null, array $options = []): Cart
{ {
$items = $this->cartItems() $items = $this->cartItems()
->with('purchasable') ->with('purchasable')
@ -161,10 +162,14 @@ trait HasShoppingCapabilities
$quantity $quantity
); );
$purchases->push($purchase); $purchase->update([
'cart_id' => $item->cart_id,
]);
// Remove item from cart // Remove item from cart
$item->delete(); $item->update([
'purchase_id' => $purchase->id,
]);
} }
$cart = $this->currentCart(); $cart = $this->currentCart();
@ -172,7 +177,7 @@ trait HasShoppingCapabilities
'converted_at' => now(), 'converted_at' => now(),
]); ]);
return $purchases; return $cart;
} }
/** /**

View File

@ -0,0 +1,17 @@
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Laravel\Cashier\Billable;
trait HasStripeAccount
{
use Billable;
}

View File

@ -44,23 +44,15 @@ class PurchaseFlowTest extends TestCase
public function user_can_add_product_to_cart() public function user_can_add_product_to_cart()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->create([ $product = Product::factory()->withPrices(1, 2999)->create([
'manage_stock' => false, 'manage_stock' => false,
]); ]);
$price = ProductPrice::create([ $cartItem = $user->addToCart($product, quantity: 2);
'purchasable_id' => $product->id,
'purchasable_type' => get_class($product),
'amount' => 2999, // in cents
'currency' => 'USD',
'is_default' => true,
]);
$cartItem = $user->addToCart($price, quantity: 2);
$this->assertInstanceOf(CartItem::class, $cartItem); $this->assertInstanceOf(CartItem::class, $cartItem);
$this->assertEquals(2, $cartItem->quantity); $this->assertEquals(2, $cartItem->quantity);
$this->assertEquals($price->id, $cartItem->purchasable_id); $this->assertEquals($product->id, $cartItem->purchasable_id);
} }
/** @test */ /** @test */
@ -122,12 +114,13 @@ class PurchaseFlowTest extends TestCase
$product1->update(['manage_stock' => true]); $product1->update(['manage_stock' => true]);
$product2->update(['manage_stock' => true]); $product2->update(['manage_stock' => true]);
$this->assertThrows(fn() => $user->checkout(), NotEnoughStockException::class); $this->assertThrows(fn() => $user->checkoutCart(), NotEnoughStockException::class);
$product1->update(['manage_stock' => false]); $product1->update(['manage_stock' => false]);
$product2->increaseStock(5); $product2->increaseStock(5);
$purchases = $user->checkout(); $cart = $user->checkoutCart();
$purchases = $cart->purchases;
$this->assertCount(2, $purchases); $this->assertCount(2, $purchases);
$this->assertEquals('unpaid', $purchases[0]->status); $this->assertEquals('unpaid', $purchases[0]->status);
@ -267,7 +260,7 @@ class PurchaseFlowTest extends TestCase
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$cart = Cart::create(['user_id' => $user->id]); $cart = Cart::create(['user_id' => $user->id]);
$product = Product::factory()->create(['manage_stock' => false]); $product = Product::factory()->create();
$purchase = ProductPurchase::create([ $purchase = ProductPurchase::create([
'user_id' => $user->id, 'user_id' => $user->id,
@ -296,7 +289,7 @@ class PurchaseFlowTest extends TestCase
if ($cart) { if ($cart) {
$this->assertNull($cart->converted_at); $this->assertNull($cart->converted_at);
$user->checkout(); $cart = $user->checkoutCart();
$this->assertNotNull($cart->fresh()->converted_at); $this->assertNotNull($cart->fresh()->converted_at);
} }
@ -331,4 +324,19 @@ class PurchaseFlowTest extends TestCase
$this->assertEquals(0, $purchase->amount_paid); $this->assertEquals(0, $purchase->amount_paid);
$this->assertGreaterThan(0, $purchase->amount); $this->assertGreaterThan(0, $purchase->amount);
} }
/** @test */
public function cart_total_is_correct_after_checkout()
{
$user = User::factory()->create();
$product1 = Product::factory()->withStocks()->withPrices(1,unit_amount:30)->create();
$product2 = Product::factory()->withStocks()->withPrices(1,unit_amount:70)->create();
$user->addToCart($product1, quantity: 1);
$user->addToCart($product2, quantity: 2);
$cart = $user->checkoutCart();
$this->assertEquals(170.00, $cart->getTotal());
}
} }

View File

@ -0,0 +1,154 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
use Laravel\Cashier\Cashier;
use Stripe\PaymentMethod;
use Stripe\Customer;
use Stripe\Stripe;
class StripeChargeFlowTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Set Stripe test keys
config([
'cashier.key' => env('STRIPE_KEY', 'sk_test_fake'),
'cashier.secret' => env('STRIPE_SECRET', 'sk_test_fake'),
]);
Stripe::setApiKey(config('cashier.secret'));
}
/** @test */
public function user_can_be_created()
{
$user = User::factory()->create([
'email' => 'test@example.com',
'name' => 'Test User',
]);
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
'name' => 'Test User',
]);
$this->assertInstanceOf(User::class, $user);
}
/** @test */
public function user_can_have_stripe_account()
{
$user = User::factory()->create();
// sync with stripe
$user->createAsStripeCustomer();
$this->assertNotNull($user->stripe_id);
$this->assertStringStartsWith('cus_', $user->stripe_id);
// Retrieve from Stripe to verify
$stripeCustomer = Customer::retrieve($user->stripe_id);
$this->assertEquals($user->email, $stripeCustomer->email);
// Delete the customer from Stripe after test
$stripeCustomer->delete();
}
/** @test */
public function user_has_stripe_account_trait()
{
$user = User::factory()->create();
$this->assertTrue(
in_array('Blax\Shop\Traits\HasStripeAccount', class_uses_recursive($user)),
'User model should use HasStripeAccount trait'
);
}
/** @test */
public function user_can_update_billing_address()
{
$user = User::factory()->create();
// sync with stripe
$user->createAsStripeCustomer();
$payload = [
'name' => $user->stripeName(),
'email' => $user->stripeEmail(),
'phone' => $user->stripePhone(),
'address' => [
'line1' => '123 Test St',
'line2' => 'Apt 4',
'city' => 'Testville',
'state' => 'TS',
'postal_code' => '12345',
'country' => 'US',
],
'preferred_locales' => $user->stripePreferredLocales(),
'metadata' => $user->stripeMetadata(),
];
$user->updateStripeCustomer($payload);
$stripeCustomer = Customer::retrieve($user->stripe_id);
$this->assertEquals($payload['email'], $stripeCustomer->email);
$this->assertEquals($payload['name'], $stripeCustomer->name);
$this->assertEquals($payload['phone'], $stripeCustomer->phone);
$this->assertEquals($payload['address'], $stripeCustomer->address->toArray());
$this->assertEquals($payload['preferred_locales'], $stripeCustomer->preferred_locales);
$this->assertEquals($payload['metadata'], $stripeCustomer->metadata->toArray());
// Clean up
$stripeCustomer->delete();
}
/** @test */
public function user_can_checkout_with_stripe()
{
$user = User::factory()->create();
$product = Product::factory()
->withPrices()
->withStocks(100)
->create();
$user->addToCart($product->prices()->first(), quantity: 2);
$user->createOrGetStripeCustomer();
// Attach test payment method
$pm = \Stripe\PaymentMethod::create([
'type' => 'card',
'card' => ['token' => 'tok_visa'],
]);
$user->addPaymentMethod($pm->id);
$user->updateDefaultPaymentMethod($pm->id);
//
// Perform charge
$charge = $user->charge(1000, $pm->id, [
'currency' => 'usd',
'description' => 'Test Charge',
'payment_method_types' => ['card'],
]);
$this->assertSame('succeeded', $charge->status);
}
}

View File

@ -57,5 +57,8 @@ abstract class TestCase extends Orchestra
// Run package migrations // Run package migrations
$migration = include __DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub'; $migration = include __DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub';
$migration->up(); $migration->up();
$migration = include __DIR__ . '/../database/migrations/add_stripe_to_users_table.php.stub';
$migration->up();
} }
} }