diff --git a/.gitignore b/.gitignore
index c592599..0621109 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,7 @@ composer.lock
.idea/
workbench
.phpunit.result.cache
-.phpunit.cache
\ No newline at end of file
+.phpunit.cache
+.env
+phpunit.xml
+*dist*
\ No newline at end of file
diff --git a/README.md b/README.md
index 4c1f5b5..df4cc43 100644
--- a/README.md
+++ b/README.md
@@ -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)) {
diff --git a/composer.json b/composer.json
index 7db7fc5..3f2ef85 100644
--- a/composer.json
+++ b/composer.json
@@ -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
-}
+}
\ No newline at end of file
diff --git a/database/migrations/add_stripe_to_users_table.php.stub b/database/migrations/add_stripe_to_users_table.php.stub
new file mode 100644
index 0000000..404bc8f
--- /dev/null
+++ b/database/migrations/add_stripe_to_users_table.php.stub
@@ -0,0 +1,50 @@
+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',
+ ]);
+ }
+ });
+ }
+ }
+};
diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub
index 9edb064..4b64107 100644
--- a/database/migrations/create_blax_shop_tables.php.stub
+++ b/database/migrations/create_blax_shop_tables.php.stub
@@ -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();
diff --git a/docs/02-stripe.md b/docs/02-stripe.md
index 6603bff..44afb13 100644
--- a/docs/02-stripe.md
+++ b/docs/02-stripe.md
@@ -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) {
diff --git a/docs/03-purchasing.md b/docs/03-purchasing.md
index 936e5c0..e8ec3c1 100644
--- a/docs/03-purchasing.md
+++ b/docs/03-purchasing.md
@@ -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!');
diff --git a/phpunit.xml b/phpunit.xml
index 3fd6613..be972b8 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -29,5 +29,7 @@
+
+
\ No newline at end of file
diff --git a/src/Models/Cart.php b/src/Models/Cart.php
index bd45a41..eef88d9 100644
--- a/src/Models/Cart.php
+++ b/src/Models/Cart.php
@@ -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,
]);
diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php
index b245a72..43d2a44 100644
--- a/src/Models/CartItem.php
+++ b/src/Models/CartItem.php
@@ -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)) {
diff --git a/src/Models/Product.php b/src/Models/Product.php
index 8be5bd1..58e4bc8 100644
--- a/src/Models/Product.php
+++ b/src/Models/Product.php
@@ -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
diff --git a/src/Models/ProductPrice.php b/src/Models/ProductPrice.php
index e6c18af..c0cd683 100644
--- a/src/Models/ProductPrice.php
+++ b/src/Models/ProductPrice.php
@@ -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;
diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php
index 39d0e12..2e6ee9f 100644
--- a/src/ShopServiceProvider.php
+++ b/src/ShopServiceProvider.php
@@ -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)
diff --git a/src/Traits/HasCart.php b/src/Traits/HasCart.php
index 1ffcd01..8584563 100644
--- a/src/Traits/HasCart.php
+++ b/src/Traits/HasCart.php
@@ -34,7 +34,7 @@ trait HasCart
*
* @return Cart
*/
- public function currentCart()
+ public function currentCart() : \Blax\Shop\Models\Cart
{
return $this->cart()
->whereNull('converted_at')
diff --git a/src/Traits/HasShoppingCapabilities.php b/src/Traits/HasShoppingCapabilities.php
index 5781e7e..4a2a0c9 100644
--- a/src/Traits/HasShoppingCapabilities.php
+++ b/src/Traits/HasShoppingCapabilities.php
@@ -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;
}
/**
diff --git a/src/Traits/HasStripeAccount.php b/src/Traits/HasStripeAccount.php
new file mode 100644
index 0000000..897f8ab
--- /dev/null
+++ b/src/Traits/HasStripeAccount.php
@@ -0,0 +1,17 @@
+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());
+ }
}
diff --git a/tests/Feature/StripeChargeFlowTest.php b/tests/Feature/StripeChargeFlowTest.php
new file mode 100644
index 0000000..091123f
--- /dev/null
+++ b/tests/Feature/StripeChargeFlowTest.php
@@ -0,0 +1,154 @@
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/tests/TestCase.php b/tests/TestCase.php
index e71695b..f119060 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -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();
}
}