diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9bd0391 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# STRIPE_KEY= +# STRIPE_SECRET= \ No newline at end of file diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 0000000..f620d16 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,62 @@ +withStocks()->withPrices(1, 782)->create(); + // $product2 = Product::factory()->withStocks()->withPrices(1, 402)->create(); + // $product3 = Product::factory()->withStocks()->withPrices(1, 855)->create(); + + // $cart->addToCart($product1); + // $cart->addToCart($product2); + // $cart->addToCart($product3); + + return []; + } + + public function forCustomer(Model $model): static + { + return $this->state([ + 'customer_type' => get_class($model), + 'customer_id' => $model->getKey(), + ]); + } + + public function withNewProductInCart( + int $quantity = 1, + float $unit_amount, + float|null $sale_unit_amount = null, + int|null $stocks = 0, + Carbon|null $sale_start = null, + Carbon|null $sale_end = null, + ): static { + return $this->afterCreating(function (Cart $cart) use ( + $quantity, + $unit_amount, + $sale_unit_amount, + $stocks, + $sale_start, + $sale_end + ) { + $product = Product::factory() + ->withStocks($stocks ?? 0) + ->withPrices(1, $unit_amount, $sale_unit_amount) + ->onSale($sale_start, $sale_end) + ->create(); + + $cart->addToCart($product, $quantity); + }); + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php deleted file mode 100644 index 3af524e..0000000 --- a/database/factories/OrderFactory.php +++ /dev/null @@ -1,47 +0,0 @@ - 'ORD-' . strtoupper($this->faker->bothify('####-????')), - 'customer_id' => null, - 'customer_email' => $this->faker->safeEmail(), - 'customer_first_name' => $this->faker->firstName(), - 'customer_last_name' => $this->faker->lastName(), - 'status' => 'pending', - 'payment_status' => 'pending', - 'subtotal' => 0, - 'tax_total' => 0, - 'shipping_total' => 0, - 'discount_total' => 0, - 'total' => 0, - 'currency' => 'USD', - 'payment_method' => 'stripe', - ]; - } - - public function completed(): static - { - return $this->state([ - 'status' => 'completed', - 'payment_status' => 'paid', - ]); - } - - public function cancelled(): static - { - return $this->state([ - 'status' => 'cancelled', - 'payment_status' => 'failed', - ]); - } -} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 6a69a92..12a2ee2 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -3,6 +3,7 @@ namespace Blax\Shop\Database\Factories; use Blax\Shop\Models\Product; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; @@ -55,15 +56,19 @@ class ProductFactory extends Factory return $this->state(['featured' => true]); } - public function withPrices(int $count = 1, null|float $unit_amount = null): static - { - return $this->afterCreating(function (Product $product) use ($count, $unit_amount) { + public function withPrices( + int $count = 1, + null|float $unit_amount = null, + null|float $sale_unit_amount = null + ): static { + return $this->afterCreating(function (Product $product) use ($count, $unit_amount, $sale_unit_amount) { $prices = \Blax\Shop\Models\ProductPrice::factory() ->count($count) ->create([ 'purchasable_type' => get_class($product), 'purchasable_id' => $product->id, 'unit_amount' => $unit_amount ?? $this->faker->randomFloat(2, 10, 1000), + 'sale_unit_amount' => $sale_unit_amount, 'currency' => 'EUR', ]); @@ -76,10 +81,18 @@ class ProductFactory extends Factory }); } - public function withStocks(int $quantity = 10) : static + public function withStocks(int $quantity = 10): static { return $this->afterCreating(function (Product $product) use ($quantity) { $product->increaseStock($quantity); }); } + + public function onSale(Carbon|null $sale_start, Carbon|null $sale_end = null) + { + return $this->state([ + 'sale_start' => $sale_start, + 'sale_end' => $sale_end, + ]); + } } diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index 4b64107..bc79f84 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -321,9 +321,11 @@ return new class extends Migration $table->string('type')->nullable(); // card, bank_account, etc. $table->string('name')->nullable(); // Custom name given by user $table->string('last_digits')->nullable(); // Last 4 digits of card/account + $table->string('last_alphanumeric')->nullable(); // Last characters for non-numeric identifiers (e.g., crypto/wallet) $table->string('brand')->nullable(); // visa, mastercard, etc. $table->integer('exp_month')->nullable(); $table->integer('exp_year')->nullable(); + $table->timestamp('expires_at')->nullable(); // General expiration timestamp for non-card methods $table->boolean('is_default')->default(false); $table->boolean('is_active')->default(true); $table->json('meta')->nullable(); diff --git a/phpunit.xml b/phpunit.xml index be972b8..cf6b9cd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -2,25 +2,31 @@ tests - + ./src ./src/database - + + @@ -29,7 +35,5 @@ - - \ No newline at end of file diff --git a/src/Http/Controllers/Api/PaymentMethodController.php b/src/Http/Controllers/Api/PaymentMethodController.php new file mode 100644 index 0000000..3524954 --- /dev/null +++ b/src/Http/Controllers/Api/PaymentMethodController.php @@ -0,0 +1,92 @@ +user(); + $methods = PaymentMethod::whereHas('paymentProviderIdentity', function ($q) use ($user) { + $q->where('customer_id', $user->getKey()) + ->where('customer_type', get_class($user)); + })->active()->get(); + return response()->json($methods); + } + + // Store a new payment method (provider-agnostic) + public function store(Request $request) + { + $user = $request->user(); + $data = $request->validate([ + 'provider' => 'required|string', + 'provider_payment_method_id' => 'required|string', + 'type' => 'nullable|string', + 'name' => 'nullable|string', + 'last_digits' => 'nullable|string', + 'brand' => 'nullable|string', + 'exp_month' => 'nullable|integer', + 'exp_year' => 'nullable|integer', + 'is_default' => 'boolean', + 'meta' => 'array', + ]); + // Find or create PaymentProviderIdentity for user/provider + $providerIdentity = \Blax\Shop\Models\PaymentProviderIdentity::firstOrCreate([ + 'customer_id' => $user->getKey(), + 'customer_type' => get_class($user), + 'provider_name' => $data['provider'], + ]); + $method = PaymentMethod::create(array_merge($data, [ + 'payment_provider_identity_id' => $providerIdentity->id, + ])); + return response()->json($method, 201); + } + + // Show a specific payment method + public function show($id, Request $request) + { + $user = $request->user(); + $method = PaymentMethod::where('id', $id) + ->whereHas('paymentProviderIdentity', function ($q) use ($user) { + $q->where('customer_id', $user->getKey()) + ->where('customer_type', get_class($user)); + })->firstOrFail(); + return response()->json($method); + } + + // Update a payment method + public function update($id, Request $request) + { + $user = $request->user(); + $method = PaymentMethod::where('id', $id) + ->whereHas('paymentProviderIdentity', function ($q) use ($user) { + $q->where('customer_id', $user->getKey()) + ->where('customer_type', get_class($user)); + })->firstOrFail(); + $data = $request->validate([ + 'name' => 'nullable|string', + 'is_default' => 'boolean', + 'meta' => 'array', + ]); + $method->update($data); + return response()->json($method); + } + + // Delete a payment method + public function destroy($id, Request $request) + { + $user = $request->user(); + $method = PaymentMethod::where('id', $id) + ->whereHas('paymentProviderIdentity', function ($q) use ($user) { + $q->where('customer_id', $user->getKey()) + ->where('customer_type', get_class($user)); + })->firstOrFail(); + $method->delete(); + return response()->json(['deleted' => true]); + } +} diff --git a/src/Models/Cart.php b/src/Models/Cart.php index eef88d9..9ccb1d8 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -5,15 +5,14 @@ namespace Blax\Shop\Models; use Blax\Shop\Contracts\Cartable; use Blax\Workkit\Traits\HasExpiration; use Illuminate\Database\Eloquent\Concerns\HasUuids; +use Illuminate\Database\Eloquent\Factories\HasFactory; 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 { - use HasUuids, HasExpiration; + use HasUuids, HasExpiration, HasFactory; protected $fillable = [ 'session_id', @@ -73,6 +72,22 @@ class Cart extends Model return $this->items->sum('quantity'); } + public function getUnpaidAmount(): float + { + $paidAmount = $this->purchases() + ->whereColumn('total_amount', '!=', 'amount_paid') + ->sum('total_amount'); + + return max(0, $this->getTotal() - $paidAmount); + } + + public function getPaidAmount(): float + { + return $this->purchases() + ->whereColumn('total_amount', '!=', 'amount_paid') + ->sum('total_amount'); + } + public function isExpired(): bool { return $this->expires_at && $this->expires_at->isPast(); @@ -105,6 +120,13 @@ class Cart extends Model ->where('customer_type', $userModel); } + public static function scopeUnpaid($query) + { + return $query->whereDoesntHave('purchases', function ($q) { + $q->whereColumn('total_amount', '!=', 'amount_paid'); + }); + } + protected static function booted() { static::deleting(function ($cart) { @@ -113,10 +135,10 @@ class Cart extends Model } public function addToCart( - Model $cartable, + Model $cartable, $quantity = 1, $parameters = [] - ) : CartItem { + ): CartItem { // $cartable must implement Cartable if (! $cartable instanceof Cartable) { @@ -137,4 +159,41 @@ class Cart extends Model return $cartItem; } + + public function checkout(): static + { + $items = $this->items() + ->with('purchasable') + ->get(); + + if ($items->isEmpty()) { + throw new \Exception("Cart is empty"); + } + + // Create ProductPurchase for each cart item + foreach ($items as $item) { + $product = $item->purchasable; + $quantity = $item->quantity; + + $purchase = $this->customer->purchase( + $product->prices()->first(), + $quantity + ); + + $purchase->update([ + 'cart_id' => $item->cart_id, + ]); + + // Remove item from cart + $item->update([ + 'purchase_id' => $purchase->id, + ]); + } + + $this->update([ + 'converted_at' => now(), + ]); + + return $this; + } } diff --git a/src/Models/PaymentMethod.php b/src/Models/PaymentMethod.php index d6abae4..ece31f2 100644 --- a/src/Models/PaymentMethod.php +++ b/src/Models/PaymentMethod.php @@ -19,9 +19,11 @@ class PaymentMethod extends Model 'type', 'name', 'last_digits', + 'last_alphanumeric', 'brand', 'exp_month', 'exp_year', + 'expires_at', 'is_default', 'is_active', 'meta', @@ -30,6 +32,7 @@ class PaymentMethod extends Model protected $casts = [ 'exp_month' => 'integer', 'exp_year' => 'integer', + 'expires_at' => 'datetime', 'is_default' => 'boolean', 'is_active' => 'boolean', 'meta' => 'object', @@ -62,14 +65,20 @@ class PaymentMethod extends Model */ public function isExpired(): bool { - if (!$this->exp_month || !$this->exp_year) { - return false; + $now = now(); + + // Prefer explicit timestamp if provided (for non-card methods like crypto wallets) + if ($this->expires_at) { + return $now->isAfter($this->expires_at); } - $now = now(); - $expirationDate = now()->setYear($this->exp_year)->setMonth($this->exp_month)->endOfMonth(); + // Fallback to month/year for card-like methods + if ($this->exp_month && $this->exp_year) { + $expirationDate = now()->setYear($this->exp_year)->setMonth($this->exp_month)->endOfMonth(); + return $now->isAfter($expirationDate); + } - return $now->isAfter($expirationDate); + return false; } /** diff --git a/src/Traits/HasCart.php b/src/Traits/HasCart.php index 8584563..a89a577 100644 --- a/src/Traits/HasCart.php +++ b/src/Traits/HasCart.php @@ -4,6 +4,7 @@ namespace Blax\Shop\Traits; use Blax\Shop\Exceptions\MultiplePurchaseOptions; use Blax\Shop\Exceptions\NotPurchasable; +use Blax\Shop\Models\Cart; use Blax\Shop\Models\CartItem; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPrice; @@ -15,7 +16,7 @@ trait HasCart public function cart(): MorphMany { return $this->morphMany( - config('shop.models.cart', \Blax\Shop\Models\Cart::class), + config('shop.models.cart', Cart::class), 'customer' ); } @@ -33,8 +34,8 @@ trait HasCart * Get or create the current cart for the entity * * @return Cart - */ - public function currentCart() : \Blax\Shop\Models\Cart + */ + public function currentCart(): Cart { return $this->cart() ->whereNull('converted_at') @@ -52,8 +53,8 @@ trait HasCart * @throws \Exception */ public function addToCart(Product|ProductPrice $product_or_price, int $quantity = 1, array $parameters = []): CartItem - { - if ($product_or_price instanceof ProductPrice){ + { + if ($product_or_price instanceof ProductPrice) { $product = $product_or_price->purchasable; if ($product instanceof Product) { @@ -166,4 +167,4 @@ trait HasCart // Override this method if you need custom cart ID logic return 'cart_' . $this->getKey(); } -} \ No newline at end of file +} diff --git a/src/Traits/HasShoppingCapabilities.php b/src/Traits/HasShoppingCapabilities.php index 4a2a0c9..a2b1844 100644 --- a/src/Traits/HasShoppingCapabilities.php +++ b/src/Traits/HasShoppingCapabilities.php @@ -78,7 +78,7 @@ trait HasShoppingCapabilities } $product = $price->purchasable; - + // product must have interface Purchasable if (!in_array('Blax\Shop\Contracts\Purchasable', class_implements($product))) { throw new \Exception("The product is not purchasable"); @@ -140,44 +140,16 @@ trait HasShoppingCapabilities * @return Cart * @throws \Exception */ - public function checkoutCart(?string $cartId = null, array $options = []): Cart + public function checkoutCart(?string $cartId = null): Cart { - $items = $this->cartItems() - ->with('purchasable') - ->get(); + $cart = Cart::where('id', $cartId) + ->where('customer_id', $this->getKey()) + ->where('customer_type', get_class($this)) + ->first(); - if ($items->isEmpty()) { - throw new \Exception("Cart is empty"); - } + $cart ??= $this->currentCart(); - $purchases = collect(); - - // Create ProductPurchase for each cart item - foreach ($items as $item) { - $product = $item->purchasable; - $quantity = $item->quantity; - - $purchase = $this->purchase( - $product->prices()->first(), - $quantity - ); - - $purchase->update([ - 'cart_id' => $item->cart_id, - ]); - - // Remove item from cart - $item->update([ - 'purchase_id' => $purchase->id, - ]); - } - - $cart = $this->currentCart(); - $cart->update([ - 'converted_at' => now(), - ]); - - return $cart; + return $cart->checkout(); } /** diff --git a/tests/Feature/CartManagementTest.php b/tests/Feature/CartManagementTest.php index 2fdd993..7e06a9c 100644 --- a/tests/Feature/CartManagementTest.php +++ b/tests/Feature/CartManagementTest.php @@ -50,9 +50,9 @@ class CartManagementTest extends TestCase 'purchasable_id' => $product->id, 'purchasable_type' => get_class($product), ]); - + $cart = Cart::create(); - + $cartItem = CartItem::create([ 'cart_id' => $cart->id, 'purchasable_id' => $price->id, @@ -282,7 +282,7 @@ class CartManagementTest extends TestCase ]); $cartItem = $cart->addToCart( - $productPrice, + $productPrice, quantity: 1, parameters: [ 'color' => 'blue', @@ -307,13 +307,13 @@ class CartManagementTest extends TestCase ]); $cart->addToCart( - $productPrice, + $productPrice, quantity: 1, parameters: ['size' => 'small'] ); $cart->addToCart( - $productPrice, + $productPrice, quantity: 2, parameters: ['size' => 'large'] ); @@ -334,7 +334,7 @@ class CartManagementTest extends TestCase ]); $cartItem = $cart->addToCart( - $productPrice, + $productPrice, quantity: 1, ); @@ -344,4 +344,55 @@ class CartManagementTest extends TestCase $this->assertDatabaseMissing('cart_items', ['id' => $cartItemId]); } + + /** @test */ + public function it_calculats_unpaid_and_paid_and_can_scope() + { + $user = User::factory()->create(); + $cart = $user->currentCart(); + $product1 = Product::factory()->withStocks()->withPrices(1, 782)->create(); + $product2 = Product::factory()->withStocks()->withPrices(1, 402)->create(); + $product3 = Product::factory()->withStocks()->withPrices(1, 855)->create(); + + $cart->addToCart($product1); + $cart->addToCart($product2); + $cart->addToCart($product3); + + $this->assertEquals(2039, $cart->getUnpaidAmount(), 'Unpaid amount should equal total cart amount initially.'); + $this->assertEquals(0, $cart->getPaidAmount(), 'Paid amount should be zero initially.'); + $this->assertEquals(2039, $cart->getTotal(), 'Total amount should equal sum of paid and unpaid amounts.'); + $this->assertEquals($user->currentCart()->id, Cart::unpaid()->first()->id, 'Unpaid cart scope should return the current cart.'); + } + + /** @test */ + public function it_can_create_cart_with_factory() + { + $cart = Cart::factory()->create(); + + $this->assertNotNull($cart); + $this->assertDatabaseHas('carts', [ + 'id' => $cart->id, + ]); + + $cartWithProduct = Cart::factory() + ->withNewProductInCart( + quantity: 2, + unit_amount: 150.00, + sale_unit_amount: 120.00, + stocks: 10 + ) + ->withNewProductInCart( + quantity: 2, + unit_amount: 150.00, + sale_unit_amount: 120.00, + stocks: 10, + sale_start: now()->subDay(), + sale_end: now()->addDay() + ) + ->create(); + + $this->assertCount(2, $cartWithProduct->items); + $this->assertEquals(4, $cartWithProduct->getTotalItems()); + $this->assertEquals((150.00 * 2) + (120 * 2), $cartWithProduct->getTotal()); + } } diff --git a/tests/Feature/PaymentMethodFieldsTest.php b/tests/Feature/PaymentMethodFieldsTest.php new file mode 100644 index 0000000..687dd75 --- /dev/null +++ b/tests/Feature/PaymentMethodFieldsTest.php @@ -0,0 +1,80 @@ +stripe()->create(); + + $method = PaymentMethod::factory() + ->forProviderIdentity($identity) + ->create([ + 'type' => 'wallet', + 'last_alphanumeric' => 'abc123xyz', + 'expires_at' => null, + ]); + + $this->assertNotNull($method->id); + $this->assertSame('abc123xyz', $method->last_alphanumeric); + } + + public function test_expiration_via_expires_at(): void + { + $identity = PaymentProviderIdentity::factory()->stripe()->create(); + + $past = Carbon::now()->subDay(); + $future = Carbon::now()->addDay(); + + $expired = PaymentMethod::factory() + ->forProviderIdentity($identity) + ->create([ + 'type' => 'wallet', + 'expires_at' => $past, + 'exp_month' => null, + 'exp_year' => null, + ]); + $this->assertTrue($expired->isExpired()); + + $active = PaymentMethod::factory() + ->forProviderIdentity($identity) + ->create([ + 'type' => 'wallet', + 'expires_at' => $future, + 'exp_month' => null, + 'exp_year' => null, + ]); + $this->assertFalse($active->isExpired()); + } + + public function test_expiration_via_month_year(): void + { + $identity = PaymentProviderIdentity::factory()->stripe()->create(); + + $expired = PaymentMethod::factory() + ->forProviderIdentity($identity) + ->create([ + 'type' => 'card', + 'exp_month' => 1, + 'exp_year' => Carbon::now()->year - 1, + 'expires_at' => null, + ]); + $this->assertTrue($expired->isExpired()); + + $nonExpired = PaymentMethod::factory() + ->forProviderIdentity($identity) + ->create([ + 'type' => 'card', + 'exp_month' => Carbon::now()->month, + 'exp_year' => Carbon::now()->year + 1, + 'expires_at' => null, + ]); + $this->assertFalse($nonExpired->isExpired()); + } +} diff --git a/tests/Feature/PurchaseFlowTest.php b/tests/Feature/PurchaseFlowTest.php index 036c12c..2e9ddb3 100644 --- a/tests/Feature/PurchaseFlowTest.php +++ b/tests/Feature/PurchaseFlowTest.php @@ -30,7 +30,7 @@ class PurchaseFlowTest extends TestCase 'amount' => 4999, // in cents 'currency' => 'USD', ]); - + $purchase = $user->purchase($price, quantity: 1); $this->assertInstanceOf(ProductPurchase::class, $purchase); @@ -114,6 +114,9 @@ class PurchaseFlowTest extends TestCase $product1->update(['manage_stock' => true]); $product2->update(['manage_stock' => true]); + // Assert cart customer is user + $this->assertEquals($user->id, $user->currentCart()?->customer->id); + $this->assertThrows(fn() => $user->checkoutCart(), NotEnoughStockException::class); $product1->update(['manage_stock' => false]); @@ -131,8 +134,8 @@ class PurchaseFlowTest extends TestCase public function user_can_get_cart_total() { $user = User::factory()->create(); - $product1 = Product::factory()->withStocks()->withPrices(unit_amount:40)->create(); - $product2 = Product::factory()->withStocks()->withPrices(unit_amount:60)->create(); + $product1 = Product::factory()->withStocks()->withPrices(unit_amount: 40)->create(); + $product2 = Product::factory()->withStocks()->withPrices(unit_amount: 60)->create(); $this->assertNotNull($product1->getCurrentPrice()); $this->assertNotNull($product2->getCurrentPrice()); @@ -238,7 +241,7 @@ class PurchaseFlowTest extends TestCase $user = User::factory()->create(); $product = Product::factory()->withPrices(2)->withStocks(3)->create(); - $this->assertThrows(fn() => $user->addToCart($product, quantity: 5), NotEnoughStockException::class); + $this->assertThrows(fn() => $user->addToCart($product, quantity: 5), NotEnoughStockException::class); } /** @test */ @@ -329,8 +332,8 @@ class PurchaseFlowTest extends TestCase 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(); + $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); diff --git a/tests/Feature/StripeChargeFlowTest.php b/tests/Feature/StripeChargeFlowTest.php index ae4fb2d..7851cb6 100644 --- a/tests/Feature/StripeChargeFlowTest.php +++ b/tests/Feature/StripeChargeFlowTest.php @@ -8,6 +8,8 @@ use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\Cart; use Blax\Shop\Models\CartItem; +use Blax\Shop\Models\PaymentProviderIdentity; +use Blax\Shop\Models\PaymentMethod as ShopPaymentMethod; use Blax\Shop\Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use Workbench\App\Models\User; @@ -23,14 +25,16 @@ class StripeChargeFlowTest extends TestCase protected function setUp(): void { parent::setUp(); - + $key = env('STRIPE_KEY', 'your_stripe_test_key_here'); $secret = env('STRIPE_SECRET', 'your_stripe_test_secret_here'); - if (strpos($key, 'your_stripe_test_key_here') >= 0 || - strpos($secret, 'your_stripe_test_secret_here') >= 0) { + if ( + $key === 'your_stripe_test_key_here' || + $secret === 'your_stripe_test_secret_here' + ) { $this->markTestSkipped('Stripe test keys are not set in environment variables.'); - } + } // Set Stripe test keys config([ @@ -53,7 +57,7 @@ class StripeChargeFlowTest extends TestCase 'email' => 'test@example.com', 'name' => 'Test User', ]); - + $this->assertInstanceOf(User::class, $user); } @@ -140,7 +144,7 @@ class StripeChargeFlowTest extends TestCase $user->createOrGetStripeCustomer(); // Attach test payment method - $pm = \Stripe\PaymentMethod::create([ + $pm = PaymentMethod::create([ 'type' => 'card', 'card' => ['token' => 'tok_visa'], ]); @@ -159,4 +163,115 @@ class StripeChargeFlowTest extends TestCase $this->assertSame('succeeded', $charge->status); } -} \ No newline at end of file + + /** @test */ + public function payment_method_model_can_store_stripe_details() + { + $user = User::factory()->create(); + + // Ensure Stripe customer exists + $user->createOrGetStripeCustomer(); + + // Create a Stripe payment method (test card) + $pm = PaymentMethod::create([ + 'type' => 'card', + 'card' => ['token' => 'tok_visa'], + ]); + + // Attach to customer via Cashier to stay compatible + $user->addPaymentMethod($pm->id); + $user->updateDefaultPaymentMethod($pm->id); + + // Create provider identity for Stripe + $identity = PaymentProviderIdentity::findOrCreateForCustomer( + $user, + 'stripe', + $user->stripe_id + ); + + // Retrieve full details from Stripe + $spm = PaymentMethod::retrieve($pm->id); + + // Persist our provider-agnostic payment method record + $method = ShopPaymentMethod::create([ + 'payment_provider_identity_id' => $identity->id, + 'provider_payment_method_id' => $spm->id, + 'type' => $spm->type, + 'name' => null, + 'last_digits' => $spm->card->last4 ?? null, + 'last_alphanumeric' => null, + 'brand' => $spm->card->brand ?? null, + 'exp_month' => $spm->card->exp_month ?? null, + 'exp_year' => $spm->card->exp_year ?? null, + 'is_default' => true, + 'is_active' => true, + 'meta' => [], + ]); + + $this->assertNotNull($method->id); + $this->assertSame($pm->id, $method->provider_payment_method_id); + $this->assertSame('card', $method->type); + $this->assertNotEmpty($method->last_digits); + $this->assertNotEmpty($method->brand); + $this->assertTrue($method->is_default); + + // Ensure default relationship works on identity + $default = $identity->defaultPaymentMethod()->first(); + $this->assertNotNull($default); + $this->assertTrue($default->is_default); + + // Clean up: detach and delete Stripe customer + $stripeCustomer = Customer::retrieve($user->stripe_id); + $stripeCustomer->delete(); + } + + /** @test */ + public function can_switch_default_payment_method_for_provider_identity() + { + // No need to hit Stripe here; use local records + $identity = PaymentProviderIdentity::factory()->stripe()->create(); + + $first = ShopPaymentMethod::factory()->forProviderIdentity($identity)->create([ + 'provider_payment_method_id' => 'pm_first', + 'type' => 'card', + 'is_default' => true, + ]); + + $second = ShopPaymentMethod::factory()->forProviderIdentity($identity)->create([ + 'provider_payment_method_id' => 'pm_second', + 'type' => 'card', + 'is_default' => false, + ]); + + // Switch default to the second method + $second->setAsDefault(); + + $this->assertTrue($second->fresh()->is_default); + $this->assertFalse($first->fresh()->is_default); + } + + public function can_buy_and_charge_cart() + { + $user = User::factory()->create(); + $product1 = Product::factory() + ->withPrices(1, 500) + ->withStocks(10) + ->create(); + $product2 = Product::factory() + ->withPrices(1, 200) + ->withStocks(10) + ->create(); + + $user->addToCart($product1, 1); + $user->addToCart($product2, 1); + + $cart = $user->getCart(); + + $this->assertNotNull($cart); + $this->assertCount(2, $cart->items); + $this->assertEquals(700, $cart->getTotalAmount()); + + // Proceed to checkout + $cart = $cart->checkout(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..ba3b013 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +load(); +} + +// Load package-specific .env.testing +if (file_exists(__DIR__ . '/../.env.testing')) { + \Dotenv\Dotenv::createImmutable(__DIR__ . '/../', '.env.testing')->load(); +}