I tests & structure
This commit is contained in:
parent
bc8158ba7b
commit
c6c159a4ff
|
|
@ -0,0 +1,2 @@
|
|||
# STRIPE_KEY=
|
||||
# STRIPE_SECRET=
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
14
phpunit.xml
14
phpunit.xml
|
|
@ -2,25 +2,31 @@
|
|||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
processIsolation="false"
|
||||
stopOnFailure="false"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
displayDetailsOnTestsThatTriggerDeprecations="true"
|
||||
displayDetailsOnTestsThatTriggerErrors="true"
|
||||
displayDetailsOnTestsThatTriggerNotices="true"
|
||||
displayDetailsOnTestsThatTriggerWarnings="true"
|
||||
displayDetailsOnPhpunitDeprecations="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="BlaxShop Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<coverage includeUncoveredFiles="true">
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">./src</directory>
|
||||
</include>
|
||||
<exclude>
|
||||
<directory>./src/database</directory>
|
||||
</exclude>
|
||||
</coverage>
|
||||
</source>
|
||||
<coverage includeUncoveredFiles="true" />
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="DB_CONNECTION" value="mysql"/>
|
||||
|
|
@ -29,7 +35,5 @@
|
|||
<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>
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
@ -116,7 +138,7 @@ class Cart extends Model
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,16 +65,22 @@ 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();
|
||||
// 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 false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a formatted display name for the payment method.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
|
@ -34,7 +35,7 @@ trait HasCart
|
|||
*
|
||||
* @return Cart
|
||||
*/
|
||||
public function currentCart() : \Blax\Shop\Models\Cart
|
||||
public function currentCart(): Cart
|
||||
{
|
||||
return $this->cart()
|
||||
->whereNull('converted_at')
|
||||
|
|
@ -53,7 +54,7 @@ trait HasCart
|
|||
*/
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -27,8 +29,10 @@ class StripeChargeFlowTest extends TestCase
|
|||
$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.');
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/** @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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
Loading…
Reference in New Issue