A stripe & BFI cart
This commit is contained in:
parent
929e87bc28
commit
ffc8716c22
|
|
@ -6,3 +6,6 @@ composer.lock
|
|||
workbench
|
||||
.phpunit.result.cache
|
||||
.phpunit.cache
|
||||
.env
|
||||
phpunit.xml
|
||||
*dist*
|
||||
|
|
@ -93,7 +93,7 @@ $purchase = $user->purchase($product, quantity: 2, options: [
|
|||
$cartItem = $user->addToCart($product, quantity: 1);
|
||||
|
||||
// Checkout cart
|
||||
$completedPurchases = $user->checkout();
|
||||
$completedPurchases = $user->checkoutCart();
|
||||
|
||||
// Check if user has purchased
|
||||
if ($user->hasPurchased($product)) {
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@
|
|||
}
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0",
|
||||
"php": "^8.2|^8.3",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||
"illuminate/database": "^10.0|^11.0|^12.0",
|
||||
"blax-software/laravel-workkit": "dev-master|*",
|
||||
"stripe/stripe-php": "^19.0"
|
||||
"laravel/cashier": "^15.7"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "^8.0|^9.0",
|
||||
|
|
@ -55,6 +55,11 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.2.28"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -266,6 +266,7 @@ return new class extends Migration
|
|||
$table->uuid('id')->primary();
|
||||
$table->uuid('cart_id');
|
||||
$table->uuidMorphs('purchasable');
|
||||
$table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete();
|
||||
$table->integer('quantity')->default(1);
|
||||
$table->decimal('price', 10, 2)->default(0);
|
||||
$table->decimal('regular_price', 10, 2)->nullable();
|
||||
|
|
|
|||
|
|
@ -364,7 +364,7 @@ Route::get('/checkout/success', function (Request $request) {
|
|||
$user = auth()->user();
|
||||
|
||||
// Convert cart to purchases
|
||||
$purchases = $user->checkout();
|
||||
$purchases = $user->checkoutCart();
|
||||
|
||||
// Store charge ID
|
||||
foreach ($purchases as $purchase) {
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ $stats = [
|
|||
|
||||
```php
|
||||
try {
|
||||
$purchases = $user->checkout();
|
||||
$purchases = $user->checkoutCart();
|
||||
|
||||
// Checkout successful
|
||||
// Cart items are now converted to completed purchases
|
||||
|
|
@ -491,7 +491,7 @@ Route::post('/checkout', function () {
|
|||
$user = auth()->user();
|
||||
|
||||
try {
|
||||
$purchases = $user->checkout();
|
||||
$purchases = $user->checkoutCart();
|
||||
|
||||
return redirect()->route('orders.success')
|
||||
->with('success', 'Order placed successfully!');
|
||||
|
|
|
|||
|
|
@ -29,5 +29,7 @@
|
|||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="QUEUE_DRIVER" value="sync"/>
|
||||
<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>
|
||||
</phpunit>
|
||||
|
|
@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Cart extends Model
|
||||
|
|
@ -63,7 +64,7 @@ class Cart extends Model
|
|||
public function getTotal(): float
|
||||
{
|
||||
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_type' => get_class($cartable),
|
||||
'quantity' => $quantity,
|
||||
'price' => ($cartable?->sale_unit_amount ?? $cartable?->unit_amount ?? 0),
|
||||
'regular_price' => $cartable?->unit_amount,
|
||||
'subtotal' => ($cartable?->sale_unit_amount ?? $cartable?->unit_amount ?? 0) * $quantity,
|
||||
'price' => $cartable?->getCurrentPrice(),
|
||||
'regular_price' => $cartable?->getCurrentPrice(false) ?? $cartable?->unit_amount,
|
||||
'subtotal' => ($cartable?->getCurrentPrice()) * $quantity,
|
||||
'parameters' => $parameters,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class CartItem extends Model
|
|||
'regular_price',
|
||||
'subtotal',
|
||||
'parameters',
|
||||
'purchase_id',
|
||||
'meta',
|
||||
];
|
||||
|
||||
|
|
@ -66,6 +67,15 @@ class CartItem extends Model
|
|||
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
|
||||
{
|
||||
if ($this->purchasable_type === config('shop.models.product', Product::class)) {
|
||||
|
|
|
|||
|
|
@ -212,9 +212,9 @@ class Product extends Model implements Purchasable, Cartable
|
|||
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
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class ProductPrice extends Model implements Cartable
|
|||
return $query->where('active', true);
|
||||
}
|
||||
|
||||
public function getCurrentPrice($sale_price): float
|
||||
public function getCurrentPrice(bool|null $sale_price = null): float
|
||||
{
|
||||
if ($sale_price) {
|
||||
return $this->sale_unit_amount;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class ShopServiceProvider extends ServiceProvider
|
|||
// Publish migrations
|
||||
$this->publishes([
|
||||
__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');
|
||||
|
||||
// Load routes if enabled (API only)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ trait HasCart
|
|||
*
|
||||
* @return Cart
|
||||
*/
|
||||
public function currentCart()
|
||||
public function currentCart() : \Blax\Shop\Models\Cart
|
||||
{
|
||||
return $this->cart()
|
||||
->whereNull('converted_at')
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace Blax\Shop\Traits;
|
|||
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||
use Blax\Shop\Exceptions\NotPurchasable;
|
||||
use Blax\Shop\Models\Cart;
|
||||
use Blax\Shop\Models\CartItem;
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
use Blax\Shop\Models\Product;
|
||||
|
|
@ -136,10 +137,10 @@ trait HasShoppingCapabilities
|
|||
*
|
||||
* @param string|null $cartId (deprecated - not used)
|
||||
* @param array $options
|
||||
* @return Collection
|
||||
* @return Cart
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function checkout(?string $cartId = null, array $options = []): Collection
|
||||
public function checkoutCart(?string $cartId = null, array $options = []): Cart
|
||||
{
|
||||
$items = $this->cartItems()
|
||||
->with('purchasable')
|
||||
|
|
@ -161,10 +162,14 @@ trait HasShoppingCapabilities
|
|||
$quantity
|
||||
);
|
||||
|
||||
$purchases->push($purchase);
|
||||
$purchase->update([
|
||||
'cart_id' => $item->cart_id,
|
||||
]);
|
||||
|
||||
// Remove item from cart
|
||||
$item->delete();
|
||||
$item->update([
|
||||
'purchase_id' => $purchase->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$cart = $this->currentCart();
|
||||
|
|
@ -172,7 +177,7 @@ trait HasShoppingCapabilities
|
|||
'converted_at' => now(),
|
||||
]);
|
||||
|
||||
return $purchases;
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -44,23 +44,15 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_add_product_to_cart()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product = Product::factory()->create([
|
||||
$product = Product::factory()->withPrices(1, 2999)->create([
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::create([
|
||||
'purchasable_id' => $product->id,
|
||||
'purchasable_type' => get_class($product),
|
||||
'amount' => 2999, // in cents
|
||||
'currency' => 'USD',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$cartItem = $user->addToCart($price, quantity: 2);
|
||||
$cartItem = $user->addToCart($product, quantity: 2);
|
||||
|
||||
$this->assertInstanceOf(CartItem::class, $cartItem);
|
||||
$this->assertEquals(2, $cartItem->quantity);
|
||||
$this->assertEquals($price->id, $cartItem->purchasable_id);
|
||||
$this->assertEquals($product->id, $cartItem->purchasable_id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -122,12 +114,13 @@ class PurchaseFlowTest extends TestCase
|
|||
$product1->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]);
|
||||
$product2->increaseStock(5);
|
||||
|
||||
$purchases = $user->checkout();
|
||||
$cart = $user->checkoutCart();
|
||||
$purchases = $cart->purchases;
|
||||
|
||||
$this->assertCount(2, $purchases);
|
||||
$this->assertEquals('unpaid', $purchases[0]->status);
|
||||
|
|
@ -267,7 +260,7 @@ class PurchaseFlowTest extends TestCase
|
|||
{
|
||||
$user = User::factory()->create();
|
||||
$cart = Cart::create(['user_id' => $user->id]);
|
||||
$product = Product::factory()->create(['manage_stock' => false]);
|
||||
$product = Product::factory()->create();
|
||||
|
||||
$purchase = ProductPurchase::create([
|
||||
'user_id' => $user->id,
|
||||
|
|
@ -296,7 +289,7 @@ class PurchaseFlowTest extends TestCase
|
|||
if ($cart) {
|
||||
$this->assertNull($cart->converted_at);
|
||||
|
||||
$user->checkout();
|
||||
$cart = $user->checkoutCart();
|
||||
|
||||
$this->assertNotNull($cart->fresh()->converted_at);
|
||||
}
|
||||
|
|
@ -331,4 +324,19 @@ class PurchaseFlowTest extends TestCase
|
|||
$this->assertEquals(0, $purchase->amount_paid);
|
||||
$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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,5 +57,8 @@ abstract class TestCase extends Orchestra
|
|||
// Run package migrations
|
||||
$migration = include __DIR__ . '/../database/migrations/create_blax_shop_tables.php.stub';
|
||||
$migration->up();
|
||||
|
||||
$migration = include __DIR__ . '/../database/migrations/add_stripe_to_users_table.php.stub';
|
||||
$migration->up();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue