I tests & structure

This commit is contained in:
a6a2f5842 2025-11-29 12:05:02 +01:00
parent bc8158ba7b
commit c6c159a4ff
16 changed files with 558 additions and 127 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# STRIPE_KEY=
# STRIPE_SECRET=

View File

@ -0,0 +1,62 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class CartFactory extends Factory
{
protected $model = Cart::class;
public function definition(): array
{
// $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);
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);
});
}
}

View File

@ -1,47 +0,0 @@
<?php
namespace Blax\Shop\Database\Factories;
use Blax\Shop\Models\Order;
use Illuminate\Database\Eloquent\Factories\Factory;
class OrderFactory extends Factory
{
protected $model = Order::class;
public function definition(): array
{
return [
'order_number' => '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',
]);
}
}

View File

@ -3,6 +3,7 @@
namespace Blax\Shop\Database\Factories; namespace Blax\Shop\Database\Factories;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -55,15 +56,19 @@ class ProductFactory extends Factory
return $this->state(['featured' => true]); return $this->state(['featured' => true]);
} }
public function withPrices(int $count = 1, null|float $unit_amount = null): static public function withPrices(
{ int $count = 1,
return $this->afterCreating(function (Product $product) use ($count, $unit_amount) { 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() $prices = \Blax\Shop\Models\ProductPrice::factory()
->count($count) ->count($count)
->create([ ->create([
'purchasable_type' => get_class($product), 'purchasable_type' => get_class($product),
'purchasable_id' => $product->id, 'purchasable_id' => $product->id,
'unit_amount' => $unit_amount ?? $this->faker->randomFloat(2, 10, 1000), 'unit_amount' => $unit_amount ?? $this->faker->randomFloat(2, 10, 1000),
'sale_unit_amount' => $sale_unit_amount,
'currency' => 'EUR', '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) { return $this->afterCreating(function (Product $product) use ($quantity) {
$product->increaseStock($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,
]);
}
} }

View File

@ -321,9 +321,11 @@ return new class extends Migration
$table->string('type')->nullable(); // card, bank_account, etc. $table->string('type')->nullable(); // card, bank_account, etc.
$table->string('name')->nullable(); // Custom name given by user $table->string('name')->nullable(); // Custom name given by user
$table->string('last_digits')->nullable(); // Last 4 digits of card/account $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->string('brand')->nullable(); // visa, mastercard, etc.
$table->integer('exp_month')->nullable(); $table->integer('exp_month')->nullable();
$table->integer('exp_year')->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_default')->default(false);
$table->boolean('is_active')->default(true); $table->boolean('is_active')->default(true);
$table->json('meta')->nullable(); $table->json('meta')->nullable();

View File

@ -2,25 +2,31 @@
<phpunit <phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php" bootstrap="tests/bootstrap.php"
colors="true" colors="true"
processIsolation="false" processIsolation="false"
stopOnFailure="false" stopOnFailure="false"
cacheDirectory=".phpunit.cache" cacheDirectory=".phpunit.cache"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnPhpunitDeprecations="true"
> >
<testsuites> <testsuites>
<testsuite name="BlaxShop Test Suite"> <testsuite name="BlaxShop Test Suite">
<directory>tests</directory> <directory>tests</directory>
</testsuite> </testsuite>
</testsuites> </testsuites>
<coverage includeUncoveredFiles="true"> <source>
<include> <include>
<directory suffix=".php">./src</directory> <directory suffix=".php">./src</directory>
</include> </include>
<exclude> <exclude>
<directory>./src/database</directory> <directory>./src/database</directory>
</exclude> </exclude>
</coverage> </source>
<coverage includeUncoveredFiles="true" />
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="mysql"/> <env name="DB_CONNECTION" value="mysql"/>
@ -29,7 +35,5 @@
<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

@ -0,0 +1,92 @@
<?php
namespace Blax\Shop\Http\Controllers\Api;
use Blax\Shop\Models\PaymentMethod;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class PaymentMethodController extends Controller
{
// List all payment methods for the authenticated user
public function index(Request $request)
{
$user = $request->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]);
}
}

View File

@ -5,15 +5,14 @@ namespace Blax\Shop\Models;
use Blax\Shop\Contracts\Cartable; use Blax\Shop\Contracts\Cartable;
use Blax\Workkit\Traits\HasExpiration; use Blax\Workkit\Traits\HasExpiration;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
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
{ {
use HasUuids, HasExpiration; use HasUuids, HasExpiration, HasFactory;
protected $fillable = [ protected $fillable = [
'session_id', 'session_id',
@ -73,6 +72,22 @@ class Cart extends Model
return $this->items->sum('quantity'); 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 public function isExpired(): bool
{ {
return $this->expires_at && $this->expires_at->isPast(); return $this->expires_at && $this->expires_at->isPast();
@ -105,6 +120,13 @@ class Cart extends Model
->where('customer_type', $userModel); ->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() protected static function booted()
{ {
static::deleting(function ($cart) { static::deleting(function ($cart) {
@ -116,7 +138,7 @@ class Cart extends Model
Model $cartable, Model $cartable,
$quantity = 1, $quantity = 1,
$parameters = [] $parameters = []
) : CartItem { ): CartItem {
// $cartable must implement Cartable // $cartable must implement Cartable
if (! $cartable instanceof Cartable) { if (! $cartable instanceof Cartable) {
@ -137,4 +159,41 @@ class Cart extends Model
return $cartItem; 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;
}
} }

View File

@ -19,9 +19,11 @@ class PaymentMethod extends Model
'type', 'type',
'name', 'name',
'last_digits', 'last_digits',
'last_alphanumeric',
'brand', 'brand',
'exp_month', 'exp_month',
'exp_year', 'exp_year',
'expires_at',
'is_default', 'is_default',
'is_active', 'is_active',
'meta', 'meta',
@ -30,6 +32,7 @@ class PaymentMethod extends Model
protected $casts = [ protected $casts = [
'exp_month' => 'integer', 'exp_month' => 'integer',
'exp_year' => 'integer', 'exp_year' => 'integer',
'expires_at' => 'datetime',
'is_default' => 'boolean', 'is_default' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'meta' => 'object', 'meta' => 'object',
@ -62,16 +65,22 @@ class PaymentMethod extends Model
*/ */
public function isExpired(): bool public function isExpired(): bool
{ {
if (!$this->exp_month || !$this->exp_year) { $now = now();
return false;
// Prefer explicit timestamp if provided (for non-card methods like crypto wallets)
if ($this->expires_at) {
return $now->isAfter($this->expires_at);
} }
$now = now(); // 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(); $expirationDate = now()->setYear($this->exp_year)->setMonth($this->exp_month)->endOfMonth();
return $now->isAfter($expirationDate); return $now->isAfter($expirationDate);
} }
return false;
}
/** /**
* Get a formatted display name for the payment method. * Get a formatted display name for the payment method.
*/ */

View File

@ -4,6 +4,7 @@ namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\MultiplePurchaseOptions; use Blax\Shop\Exceptions\MultiplePurchaseOptions;
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\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
@ -15,7 +16,7 @@ trait HasCart
public function cart(): MorphMany public function cart(): MorphMany
{ {
return $this->morphMany( return $this->morphMany(
config('shop.models.cart', \Blax\Shop\Models\Cart::class), config('shop.models.cart', Cart::class),
'customer' 'customer'
); );
} }
@ -34,7 +35,7 @@ trait HasCart
* *
* @return Cart * @return Cart
*/ */
public function currentCart() : \Blax\Shop\Models\Cart public function currentCart(): Cart
{ {
return $this->cart() return $this->cart()
->whereNull('converted_at') ->whereNull('converted_at')
@ -53,7 +54,7 @@ trait HasCart
*/ */
public function addToCart(Product|ProductPrice $product_or_price, int $quantity = 1, array $parameters = []): CartItem 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; $product = $product_or_price->purchasable;
if ($product instanceof Product) { if ($product instanceof Product) {

View File

@ -140,44 +140,16 @@ trait HasShoppingCapabilities
* @return Cart * @return Cart
* @throws \Exception * @throws \Exception
*/ */
public function checkoutCart(?string $cartId = null, array $options = []): Cart public function checkoutCart(?string $cartId = null): Cart
{ {
$items = $this->cartItems() $cart = Cart::where('id', $cartId)
->with('purchasable') ->where('customer_id', $this->getKey())
->get(); ->where('customer_type', get_class($this))
->first();
if ($items->isEmpty()) { $cart ??= $this->currentCart();
throw new \Exception("Cart is empty");
}
$purchases = collect(); return $cart->checkout();
// 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;
} }
/** /**

View File

@ -344,4 +344,55 @@ class CartManagementTest extends TestCase
$this->assertDatabaseMissing('cart_items', ['id' => $cartItemId]); $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());
}
} }

View File

@ -0,0 +1,80 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Models\PaymentMethod;
use Blax\Shop\Models\PaymentProviderIdentity;
use Blax\Shop\Tests\TestCase;
use Illuminate\Support\Carbon;
class PaymentMethodFieldsTest extends TestCase
{
public function test_can_store_last_alphanumeric(): void
{
$identity = PaymentProviderIdentity::factory()->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());
}
}

View File

@ -114,6 +114,9 @@ class PurchaseFlowTest extends TestCase
$product1->update(['manage_stock' => true]); $product1->update(['manage_stock' => true]);
$product2->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); $this->assertThrows(fn() => $user->checkoutCart(), NotEnoughStockException::class);
$product1->update(['manage_stock' => false]); $product1->update(['manage_stock' => false]);
@ -131,8 +134,8 @@ class PurchaseFlowTest extends TestCase
public function user_can_get_cart_total() public function user_can_get_cart_total()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->withStocks()->withPrices(unit_amount:40)->create(); $product1 = Product::factory()->withStocks()->withPrices(unit_amount: 40)->create();
$product2 = Product::factory()->withStocks()->withPrices(unit_amount:60)->create(); $product2 = Product::factory()->withStocks()->withPrices(unit_amount: 60)->create();
$this->assertNotNull($product1->getCurrentPrice()); $this->assertNotNull($product1->getCurrentPrice());
$this->assertNotNull($product2->getCurrentPrice()); $this->assertNotNull($product2->getCurrentPrice());
@ -329,8 +332,8 @@ class PurchaseFlowTest extends TestCase
public function cart_total_is_correct_after_checkout() public function cart_total_is_correct_after_checkout()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->withStocks()->withPrices(1,unit_amount:30)->create(); $product1 = Product::factory()->withStocks()->withPrices(1, unit_amount: 30)->create();
$product2 = Product::factory()->withStocks()->withPrices(1,unit_amount:70)->create(); $product2 = Product::factory()->withStocks()->withPrices(1, unit_amount: 70)->create();
$user->addToCart($product1, quantity: 1); $user->addToCart($product1, quantity: 1);
$user->addToCart($product2, quantity: 2); $user->addToCart($product2, quantity: 2);

View File

@ -8,6 +8,8 @@ use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\Cart; use Blax\Shop\Models\Cart;
use Blax\Shop\Models\CartItem; use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\PaymentProviderIdentity;
use Blax\Shop\Models\PaymentMethod as ShopPaymentMethod;
use Blax\Shop\Tests\TestCase; use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User; use Workbench\App\Models\User;
@ -27,8 +29,10 @@ class StripeChargeFlowTest extends TestCase
$key = env('STRIPE_KEY', 'your_stripe_test_key_here'); $key = env('STRIPE_KEY', 'your_stripe_test_key_here');
$secret = env('STRIPE_SECRET', 'your_stripe_test_secret_here'); $secret = env('STRIPE_SECRET', 'your_stripe_test_secret_here');
if (strpos($key, 'your_stripe_test_key_here') >= 0 || if (
strpos($secret, 'your_stripe_test_secret_here') >= 0) { $key === 'your_stripe_test_key_here' ||
$secret === 'your_stripe_test_secret_here'
) {
$this->markTestSkipped('Stripe test keys are not set in environment variables.'); $this->markTestSkipped('Stripe test keys are not set in environment variables.');
} }
@ -140,7 +144,7 @@ class StripeChargeFlowTest extends TestCase
$user->createOrGetStripeCustomer(); $user->createOrGetStripeCustomer();
// Attach test payment method // Attach test payment method
$pm = \Stripe\PaymentMethod::create([ $pm = PaymentMethod::create([
'type' => 'card', 'type' => 'card',
'card' => ['token' => 'tok_visa'], 'card' => ['token' => 'tok_visa'],
]); ]);
@ -159,4 +163,115 @@ class StripeChargeFlowTest extends TestCase
$this->assertSame('succeeded', $charge->status); $this->assertSame('succeeded', $charge->status);
} }
/** @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();
}
} }

13
tests/bootstrap.php Normal file
View File

@ -0,0 +1,13 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
// Load package-specific .env
if (file_exists(__DIR__ . '/../.env')) {
\Dotenv\Dotenv::createImmutable(__DIR__ . '/../', '.env')->load();
}
// Load package-specific .env.testing
if (file_exists(__DIR__ . '/../.env.testing')) {
\Dotenv\Dotenv::createImmutable(__DIR__ . '/../', '.env.testing')->load();
}