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(); } }