A prompts, I docs/readme, BF orders, R tests locations

This commit is contained in:
Fabian @ Blax Software 2025-12-30 09:29:43 +01:00
parent 7aeffd27a9
commit 136b7ade63
82 changed files with 3881 additions and 582 deletions

25
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,25 @@
# GitHub Copilot Instructions
You are an expert AI developer working on the `laravel-shop` package. To ensure consistency and quality, you must adhere to the following instructions and context files located in the `.github/` directory.
## 📚 Context & Documentation
**You are required to read and understand the following files to gain necessary context about the project structure, data models, and behaviors:**
- **[Repository Overview](repository.md)**: Contains the high-level project structure, key concepts, and architectural decisions. Read this first to understand the "what" and "why".
- **[Data Models](models.md)**: Detailed documentation of the core Eloquent models, their relationships, Enums, and key attributes. **Consult this before modifying or creating models.**
- **[Traits & Behaviors](traits.md)**: A guide to the reusable traits used across the system (e.g., `HasStocks`, `HasCart`). **Check this to avoid duplicating logic.**
- **[Kaizen / Rules](kaizen.md)**: The "Continuous Improvement" log. Contains specific rules and prompt improvements derived from previous sessions.
## 🛠️ Operational Rules
1. **Update Documentation**: As per `.github/kaizen.md`, whenever you make changes to the codebase (logic, models, configuration), you **MUST** update the corresponding documentation in the `./docs/*` directory.
2. **Follow Project Structure**: Use the structure defined in `repository.md`. Do not create files outside the standard package structure (`src/`, `tests/`, `database/`, etc.) unless explicitly instructed.
3. **Use Enums**: Always use the Enums defined in `src/Enums/` instead of hardcoded strings for statuses, types, etc. (See `models.md` for reference).
4. **Test Driven**: When implementing features, ensure you are running or creating tests in `tests/`.
## 🔍 How to use these files
When you start a task:
1. Check `repository.md` to orient yourself.
2. If the task involves database changes or model logic, read `models.md`.
3. If the task involves shared logic, read `traits.md`.
4. Before finishing, check `kaizen.md` to ensure you haven't violated any persistent rules.

11
.github/kaizen.md vendored Normal file
View File

@ -0,0 +1,11 @@
# KAIZEN
## Goal
The editing agent should improve the quality of the prompts in .github/ for the next editing agent if there are any breaking changes in the repository structure, requirements or testing framework.
## Instructions
1. If the user says something like "Do not do this, do X instead" permanentize it into the repository prompts
2. "Log" important details into the prompts for future agents
3. Ensure that all instructions are clear, concise, and unambiguous
4. Avoid redundancy and ensure that the prompts are well-organized
5. Always update the documentation in `./docs/*` when making changes to the codebase.

69
.github/models.md vendored Normal file
View File

@ -0,0 +1,69 @@
# Models
The goal of this file is to not miss any important model traits, relationships, or details when working with the main models in the package.
## Core Catalog
### Product
- Central purchasable entity.
- **Types**: `Simple`, `Variable`, `Grouped`, `External`, `Booking`, `Variation`, `Pool`.
- **Status**: `Draft`, `Published`, `Archived`.
- **Key Attributes**: `sku`, `slug`, `manage_stock`, `virtual`, `downloadable`.
- **Relationships**: `prices`, `stocks`, `categories`, `attributes`, `relations`.
### ProductPrice
- Defines the cost of a product.
- **Types**: `One Time`, `Recurring` (Subscriptions).
- **Billing**: `Per Unit`, `Tiered`.
- **Key Attributes**: `currency`, `amount`, `compare_at_amount`.
- Supports multi-currency and sale prices.
### ProductStock
- Manages inventory levels.
- **Types**: `Claimed`, `Return`, `Increase`, `Decrease`.
- **Status**: `In Stock`, `Out of Stock`, `Backorder`.
- **Key Attributes**: `quantity`, `sku` (optional override).
### ProductCategory
- Hierarchical organization for products.
- **Relationships**: `parent`, `children`, `products`.
### ProductAttribute
- Custom properties (e.g., Color, Size, Material).
- **Types**: `Text`, `Select`, `Boolean`.
- Can be used for variations or information.
## Shopping Experience
### Cart
- Represents a shopping session.
- **Status**: `Active`, `Abandoned`, `Converted`, `Expired`.
- **Key Attributes**: `currency`, `total`, `tax_total`.
- Can belong to a User or be anonymous (Guest).
### CartItem
- An item within a Cart.
- Links a `Product` and a specific `ProductPrice`.
- **Key Attributes**: `quantity`, `dates` (for bookings), `configuration`.
## Order Management
### Order
- Represents a finalized transaction.
- **Status**: `Pending`, `Processing`, `On Hold`, `In Preparation`, `Ready for Pickup`, `Shipped`, `Delivered`, `Completed`.
- **Key Attributes**: `order_number`, `amount_total`, `amount_paid`, `billing_address`, `shipping_address`.
- Links to User, Cart, and Purchases.
### ProductPurchase
- An individual line item within an Order.
- Represents the immutable record of the product/price at the time of purchase.
- **Status**: `Pending`, `Unpaid`, `Completed`, `Refunded`, `Cart`, `Failed`.
- Tracks fulfillment status.
### OrderNote
- Comments or logs attached to an order.
- Can be internal or customer-visible.
## Payments & Identity
### PaymentMethod
- Stored payment details (e.g., last 4 digits, brand).
- Tokenized reference to external provider.
### PaymentProviderIdentity
- Links a local User to an external payment provider (e.g., Stripe Customer ID).

195
.github/repository.md vendored Normal file
View File

@ -0,0 +1,195 @@
# Laravel Shop - Repository Context
This document provides context for AI agents and developers working on this repository.
## Project Overview
- **Package Name**: `blax-software/laravel-shop`
- **Type**: Composer package (Laravel library)
- **Purpose**: A comprehensive headless e-commerce package for Laravel
- **License**: MIT
- **PHP Version**: 8.2+
- **Laravel Version**: 9.0 - 12.0
## Project Structure
```
├── config/ # Configuration files
│ └── shop.php # Main shop configuration
├── database/
│ ├── factories/ # Model factories for testing
│ └── migrations/ # Database migration stubs
├── docs/ # Documentation
├── routes/
│ └── api.php # API routes (webhooks, etc.)
├── src/
│ ├── Console/Commands/ # Artisan commands
│ ├── Contracts/ # Interfaces (Cartable, Chargable, Purchasable)
│ ├── Enums/ # PHP Enums (ProductType, OrderStatus, etc.)
│ ├── Events/ # Laravel events
│ ├── Exceptions/ # Custom exceptions
│ ├── Facades/ # Shop, Cart facades
│ ├── Http/ # Controllers (webhooks)
│ ├── Models/ # Eloquent models
│ ├── Services/ # Service classes
│ ├── Traits/ # Reusable traits
│ └── ShopServiceProvider.php
├── tests/
│ ├── Feature/ # Feature/integration tests
│ ├── Unit/ # Unit tests
│ ├── TestCase.php # Base test case
│ └── bootstrap.php # PHPUnit bootstrap
└── workbench/ # Orchestra Testbench workbench
```
## Key Concepts
### Product Types
- **SIMPLE**: Standalone product with no variations
- E.g., "T-shirt", "Mug", "E-book"
- **VARIABLE**: Product with variations/options
- E.g., "T-shirt" with sizes S, M, L
- **GROUPED**: Collection of related products sold together
- E.g., "Gift Set" with multiple items
- **EXTERNAL**: Product linking to external purchase site
- E.g., "Third-party course"
- **BOOKING**: Time-based bookable product
- E.g., "Hotel Room", "Consultation Slot"
- **POOL**: Dynamic pricing based on availability and grouped stocks
- E.g., "Parking Space", "Event Ticket"
### Pool Products
Pool products are complex - they consist of:
- A **pool parent** (e.g., "Parking Spaces")
- Multiple **single items** (e.g., individual parking spots)
- **Pricing strategy**: LOWEST, HIGHEST, or AVERAGE
- **Fallback pricing**: Pool can have a default price if singles don't have prices
Key pool concepts:
- `product_id` column on cart_items tracks which single is allocated
- `reallocatePoolItems()` reassigns singles when dates change
- Singles can use pool's price as fallback
### Cart System
- Authenticated users: Cart stored in database
- Guest users: Session-based cart with session ID
- Cart items track: `purchasable_id`, `purchasable_type`, `product_id`, `price_id`, `from`, `until`
- Booking items require date ranges
### Stripe Integration
- Syncs products/prices to Stripe
- Handles webhooks for checkout.session.completed
- Creates orders from completed checkout sessions
- Uses Laravel Cashier
## Commands
### Running Tests
```bash|fish
./vendor/bin/phpunit
```
## Testing
- **Framework**: PHPUnit 10+
- **Database**: SQLite in-memory for tests
- **Base Class**: `Blax\Shop\Tests\TestCase` (extends Orchestra Testbench)
- **Factories**: Located in `database/factories/`
### Writing Tests
```php
<?php
namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Tests\TestCase;
use PHPUnit\Framework\Attributes\Test;
class MyPoolTest extends TestCase
{
#[Test]
public function it_does_something()
{
// Test code
}
}
```
## Key Models
| Model | Description |
|-------------------|---------------------------------------------------|
| `Product` | Main product model with types, stock, pricing |
| `ProductPrice` | Prices for products (multi-currency, sale prices) |
| `ProductCategory` | Product categorization |
| `Cart` | Shopping cart (user or guest) |
| `CartItem` | Items in cart with quantities, dates, prices |
| `Order` | Completed orders |
| `Purchase` | Individual purchase records |
## Key Traits
| Trait | Purpose |
|---------------------------|------------------------------------------|
| `HasShoppingCapabilities` | Add to User model for purchasing ability |
| `MayBePoolProduct` | Pool product functionality |
| `HasStock` | Stock management methods |
| `Purchasable` | Make models purchasable |
## Configuration
Main config file: `config/shop.php`
Key settings:
- `shop.tables.*` - Database table names
- `shop.cache.*` - Caching configuration
- `shop.stripe.*` - Stripe integration settings
## Dependencies
### Required
- `illuminate/support` & `illuminate/database` (Laravel)
- `blax-software/laravel-workkit` (Base utilities)
- `laravel/cashier` (Stripe integration)
### Dev
- `orchestra/testbench` (Laravel package testing)
- `phpunit/phpunit` (Testing)
- `mockery/mockery` (Mocking)
## Common Patterns
### Creating a Pool Product
```php
$pool = Product::create([
'type' => ProductType::POOL,
'name' => 'Parking Spaces',
// ...
]);
$single1 = Product::create([
'type' => ProductType::BOOKING,
'name' => 'Spot A1',
// ...
]);
$pool->attachSingleItems([$single1->id]);
```
### Adding to Cart with Dates
```php
$cart->addToCart($product, 1, [], $from, $until);
```
### Checking Out
```php
$cart->checkout(); // Creates purchases, claims stock
```
## Recent Architecture Decisions
1. **`product_id` column on cart_items**: Replaced `allocated_single_item_id` in meta with a proper foreign key column to track which pool single is allocated to a cart item.
2. **Order creation in webhooks**: Stripe checkout flow creates orders in the webhook handler when the cart doesn't have a pre-existing order.
3. **Price fallback for pool singles**: Singles without prices use the pool's price as fallback.

63
.github/traits.md vendored Normal file
View File

@ -0,0 +1,63 @@
# Traits
This file documents the key traits used in the package to add functionality to models.
## Product Features
These traits are typically used on the `Product` model or other purchasable entities.
### HasPrices
- Manages the relationship with `ProductPrice`.
- Provides methods to retrieve the current price (`getCurrentPrice`), handling sales and context.
### HasStocks
- Comprehensive stock management system.
- Handles inventory tracking, stock movements (`increase`, `decrease`), and status (`In Stock`, `Out of Stock`).
- Supports date-based availability checking and stock claims (reservations).
### HasCategories
- Manages the relationship with `ProductCategory`.
- Provides scopes for filtering by category.
### HasProductRelations
- Manages relationships between products (e.g., Related, Upsells, Cross-sells, Variations).
- Provides helper methods to get specific relation types (`relatedProducts`, `variantProducts`, etc.).
### MayBePoolProduct
- Adds logic for "Pool" products (products that are collections of other single items).
- Handles complex availability and pricing calculations for pools.
- Includes `HasBookingPriceCalculation` for date-based logic.
### ChecksIfBooking
- Provides a unified way to check if an entity (Product, Cart, Order) is booking-related.
- Used to determine if date ranges are required.
### HasPricingStrategy
- Manages the pricing strategy for a product (e.g., Lowest Price, Highest Price).
- Used primarily for Pool products or complex pricing scenarios.
## Customer/User Features
These traits are designed to be added to the User model (or whatever model represents the customer).
### HasShoppingCapabilities
- A "meta-trait" that bundles `HasCart`, `HasOrders`, and `HasChargingOptions`.
- Provides the main entry point for a user to interact with the shop system.
### HasCart
- Manages the user's shopping cart.
- Provides methods to retrieve or create a cart and access cart items.
### HasOrders
- Manages the relationship with `Order`.
- Provides helper methods to filter orders by status.
### HasPaymentMethods
- Manages stored payment methods and provider identities (e.g., Stripe Customer ID).
- Essential for recurring billing and saved cards.
### HasStripeAccount
- Wraps Laravel Cashier's `Billable` trait.
- Adds Stripe-specific functionality to the user.
## Other
### HasChargingOptions
- *Currently empty/placeholder.*
- Intended for managing charging configurations.

419
README.md
View File

@ -9,7 +9,7 @@ A comprehensive headless e-commerce package for Laravel with stock management, S
## Features ## Features
- 🛍️ **Product Management** - Simple, variable, grouped, and external products - 🛍️ **Product Management** - Simple, variable, grouped, external, booking, and pool products
- 💰 **Multi-Currency Support** - Handle multiple currencies with ease - 💰 **Multi-Currency Support** - Handle multiple currencies with ease
- 📦 **Advanced Stock Management** - Stock reservations, low stock alerts, and backorders - 📦 **Advanced Stock Management** - Stock reservations, low stock alerts, and backorders
- 💳 **Stripe Integration** - Built-in Stripe product and price synchronization - 💳 **Stripe Integration** - Built-in Stripe product and price synchronization
@ -41,6 +41,14 @@ Run migrations:
php artisan migrate php artisan migrate
``` ```
## Configuration
The main configuration file is located at `config/shop.php`. Here you can configure:
- Database table names
- Caching settings
- Stripe integration keys and settings
- Currency settings
## Quick Start ## Quick Start
### Setup Your User Model ### Setup Your User Model
@ -49,6 +57,7 @@ Add the `HasShoppingCapabilities` trait to any model that should be able to purc
```php ```php
use Blax\Shop\Traits\HasShoppingCapabilities; use Blax\Shop\Traits\HasShoppingCapabilities;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable class User extends Authenticatable
{ {
@ -60,17 +69,25 @@ class User extends Authenticatable
### Creating Your First Product ### Creating Your First Product
Use the provided Enums to ensure type safety and consistency.
```php ```php
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\StockType;
$product = Product::create([ $product = Product::create([
'slug' => 'amazing-t-shirt', 'slug' => 'amazing-t-shirt',
'sku' => 'TSH-001', 'sku' => 'TSH-001',
'type' => 'simple', 'type' => ProductType::SIMPLE,
'manage_stock' => true, 'manage_stock' => true,
'status' => 'published', 'status' => ProductStatus::PUBLISHED,
'name' => 'Amazing T-Shirt', // Uses meta translation
'description' => 'A comfortable cotton t-shirt',
]); ]);
// Add Price
$product->prices()->create([ $product->prices()->create([
'currency' => 'USD', 'currency' => 'USD',
'unit_amount' => 1999, // $19.99 'unit_amount' => 1999, // $19.99
@ -78,368 +95,96 @@ $product->prices()->create([
'is_default' => true, 'is_default' => true,
]); ]);
// Manage Stock
$product->adjustStock(StockType::INCREASE, 100); // Add 100 items to stock $product->adjustStock(StockType::INCREASE, 100); // Add 100 items to stock
$product->adjustStock(StockType::DECREASE, 90); // Remove 100 items from stock $product->adjustStock(StockType::DECREASE, 10); // Remove 10 items from stock
// Reserve Stock (e.g., for a booking)
$product->adjustStock( $product->adjustStock(
StockType::CLAIMED, StockType::CLAIMED,
10, 1,
from: now(), from: now(),
until: now()->addDay(), until: now()->addDay(),
note: 'Booked' note: 'Reserved for Order #123'
); // Claim/reserve 10 stocks );
// Add translated name
$product->setLocalized('name', 'Amazing T-Shirt', 'en');
$product->setLocalized('description', 'A comfortable cotton t-shirt', 'en');
``` ```
### Working with Cart (Authenticated Users) ### Working with Cart
```php
use Blax\Shop\Facades\Cart;
use Blax\Shop\Models\Product;
$product = Product::find($productId);
$user = auth()->user();
// Add to cart (via facade)
Cart::add($product, quantity: 2);
// Or via user trait
$cartItem = $user->addToCart($product, quantity: 1);
// Get cart totals
$total = Cart::total();
$itemCount = Cart::itemCount();
// Check if cart is empty
if (Cart::isEmpty()) {
// Cart is empty
}
// Remove from cart
Cart::remove($product);
// Clear entire cart
Cart::clear();
// Checkout cart
$completedPurchases = Cart::checkout();
```
### Working with Guest Carts
```php ```php
use Blax\Shop\Facades\Cart; use Blax\Shop\Facades\Cart;
// Create or retrieve guest cart (uses session ID automatically) // Add item to cart
$guestCart = Cart::guest(); Cart::addToCart($product, 1);
// Or with specific session ID // Add item with date range (for bookings)
$guestCart = Cart::guest('custom-session-id'); Cart::addToCart($product, 1, [], now(), now()->addDay());
// Add items to guest cart // Checkout
$guestCart->addToCart($product, quantity: 1); $cart = Cart::getCart();
$cart->checkout(); // Creates purchases, claims stock, etc.
// Get guest cart totals
$total = Cart::total($guestCart);
$itemCount = Cart::itemCount($guestCart);
// Check if guest cart is empty
if (Cart::isEmpty($guestCart)) {
// Cart is empty
}
// Clear guest cart
Cart::clear($guestCart);
// Convert guest cart to user cart on login
$guestCart->convertToUserCart($user);
``` ```
### Purchasing Products Directly ## Advanced Usage
### Pool Products
Pool products are collections of single items (e.g., "Parking Spaces" containing "Spot A1", "Spot A2").
```php ```php
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Enums\ProductType;
$product = Product::find($productId); // Create the Pool Parent
$user = auth()->user(); $pool = Product::create([
'type' => ProductType::POOL,
// Simple purchase 'name' => 'Parking Spaces',
$purchase = $user->purchase($product, quantity: 1); 'manage_stock' => true, // Pool manages availability
// Purchase with options
$purchase = $user->purchase($product, quantity: 2, options: [
'price_id' => $priceId,
'charge_id' => $paymentIntent->id,
]); ]);
// Check if user has purchased // Create Single Items
if ($user->hasPurchased($product)) { $spot1 = Product::create([
// Grant access 'type' => ProductType::BOOKING,
} 'name' => 'Spot A1',
]);
$spot2 = Product::create([
'type' => ProductType::BOOKING,
'name' => 'Spot A2',
]);
// Attach Singles to Pool
$pool->attachSingleItems([$spot1->id, $spot2->id]);
``` ```
### Using Shop Facade ### Booking Products
Booking products are time-based and require `from` and `until` dates when adding to cart.
```php ```php
use Blax\Shop\Facades\Shop; use Blax\Shop\Models\Product;
use Blax\Shop\Enums\ProductType;
// Get all products $room = Product::create([
$products = Shop::products()->get(); 'type' => ProductType::BOOKING,
'name' => 'Conference Room',
'manage_stock' => true,
]);
// Get published products only // Check availability
$products = Shop::published()->get(); $isAvailable = $room->availableOnDate(now(), now()->addHour());
// Get products in stock
$products = Shop::inStock()->get();
// Get featured products
$featured = Shop::featured()->get();
// Search products
$results = Shop::search('t-shirt')->get();
// Check stock availability
if (Shop::checkStock($product, quantity: 5)) {
// Sufficient stock available
}
// Get available stock
$available = Shop::getAvailableStock($product);
// Check if product is on sale
if (Shop::isOnSale($product)) {
// Show sale badge
}
// Get configuration
$currency = Shop::currency(); // USD
$config = Shop::config('cart.expire_after_days', 30);
``` ```
## Testing
To run the package tests:
```bash
./vendor/bin/phpunit
```
The tests use an in-memory SQLite database and Orchestra Testbench.
## Documentation ## Documentation
- [Product Management](docs/01-products.md) For more detailed documentation, please refer to the `docs/` directory in the repository.
- [Stripe Integration](docs/02-stripe.md)
- [Purchasing Products](docs/03-purchasing.md)
- [Subscriptions](docs/04-subscriptions.md)
- [Stock Management](docs/05-stock.md)
- [API Usage](docs/06-api.md)
## Models
The package includes the following models:
- **Product** - Main product model with support for simple, variable, grouped, and external products
- **ProductPrice** - Multi-currency pricing with sale prices and subscription support
- **ProductCategory** - Hierarchical product categories
- **ProductStock** - Advanced stock management with reservations and logging
- **ProductAttribute** - Product attributes (size, color, material, etc.)
- **ProductPurchase** - Purchase records and history
- **ProductAction** - Custom actions triggered by product events
- **ProductActionRun** - Execution logs for product actions
- **Cart** - Shopping cart for authenticated users and guests
- **CartItem** - Individual items in a cart
- **PaymentMethod** - Saved payment methods
- **PaymentProviderIdentity** - Links users to payment providers (Stripe, etc.)
## Traits
Available traits for your models:
- **HasShoppingCapabilities** - Complete shopping functionality (cart + purchases)
- **HasCart** - Cart management functionality only
- **HasPaymentMethods** - Payment method management
- **HasStripeAccount** - Stripe integration for users
- **HasPrices** - Price management (for Product model)
- **HasStocks** - Stock management (for Product model)
- **HasCategories** - Category relationships (for Product model)
- **HasProductRelations** - Related products, upsells, cross-sells
- **HasChargingOptions** - Payment processing capabilities
## Facades
The package provides two facades for cleaner API access:
### Shop Facade
```php
use Blax\Shop\Facades\Shop;
Shop::products() // Get product query builder
Shop::product($id) // Find product by ID
Shop::categories() // Get categories query builder
Shop::inStock() // Get in-stock products
Shop::featured() // Get featured products
Shop::published() // Get published products
Shop::search($query) // Search products
Shop::checkStock($product, $qty) // Check stock availability
Shop::getAvailableStock($product) // Get available stock quantity
Shop::isOnSale($product) // Check if product is on sale
Shop::config($key, $default) // Get shop configuration
Shop::currency() // Get default currency
```
### Cart Facade
```php
use Blax\Shop\Facades\Cart;
Cart::current() // Get current user's cart
Cart::guest($sessionId) // Get/create guest cart
Cart::forUser($user) // Get cart for specific user
Cart::find($cartId) // Find cart by ID
Cart::add($product, $qty, $params) // Add item to cart
Cart::remove($product, $qty) // Remove item from cart
Cart::update($cartItem, $qty) // Update cart item quantity
Cart::clear($cart) // Clear cart items
Cart::checkout($cart) // Checkout cart
Cart::total($cart) // Get cart total
Cart::itemCount($cart) // Get item count
Cart::items($cart) // Get cart items
Cart::isEmpty($cart) // Check if cart is empty
Cart::isExpired($cart) // Check if cart is expired
Cart::isConverted($cart) // Check if cart was converted
Cart::unpaidAmount($cart) // Get unpaid amount
Cart::paidAmount($cart) // Get paid amount
```
## Configuration
The `config/shop.php` file contains all configuration options:
```php
return [
// Table names (customizable for multi-tenancy)
'tables' => [
'products' => 'products',
'product_categories' => 'product_categories',
'product_prices' => 'product_prices',
'product_stocks' => 'product_stocks',
'product_attributes' => 'product_attributes',
'product_purchases' => 'product_purchases',
'product_actions' => 'product_actions',
'product_action_runs' => 'product_action_runs',
'product_relations' => 'product_relations',
'carts' => 'carts',
'cart_items' => 'cart_items',
'cart_discounts' => 'cart_discounts',
'payment_methods' => 'payment_methods',
'payment_provider_identities' => 'payment_provider_identities',
],
// Model classes (allow overriding)
'models' => [
'product' => \Blax\Shop\Models\Product::class,
'product_price' => \Blax\Shop\Models\ProductPrice::class,
'product_category' => \Blax\Shop\Models\ProductCategory::class,
'product_stock' => \Blax\Shop\Models\ProductStock::class,
'product_attribute' => \Blax\Shop\Models\ProductAttribute::class,
'product_purchase' => \Blax\Shop\Models\ProductPurchase::class,
'cart' => \Blax\Shop\Models\Cart::class,
'cart_item' => \Blax\Shop\Models\CartItem::class,
'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class,
'payment_method' => \Blax\Shop\Models\PaymentMethod::class,
],
// API Routes
'routes' => [
'enabled' => true,
'prefix' => 'api/shop',
'middleware' => ['api'],
'name_prefix' => 'shop.',
],
// Stock management
'stock' => [
'track_inventory' => true,
'allow_backorders' => false,
'low_stock_threshold' => 5,
'log_changes' => true,
'auto_release_expired' => true,
],
// Product actions
'actions' => [
'path' => app_path('Jobs/ProductAction'),
'namespace' => 'App\\Jobs\\ProductAction',
'auto_discover' => true,
],
// Stripe integration
'stripe' => [
'enabled' => env('SHOP_STRIPE_ENABLED', false),
'sync_prices' => true,
],
// Cache configuration
'cache' => [
'enabled' => env('SHOP_CACHE_ENABLED', true),
'ttl' => 3600,
'prefix' => 'shop:',
],
// Cart configuration
'cart' => [
'expire_after_days' => 30,
'auto_cleanup' => true,
'merge_on_login' => true,
],
// API response format
'api' => [
'include_meta' => true,
'wrap_response' => true,
'response_key' => 'data',
],
];
```
## Commands
### Add Example Products
Create example products for testing and demonstration purposes:
```bash
# Create 2 products of each type (default)
php artisan shop:add-example-products
# Create 5 products of each type
php artisan shop:add-example-products --count=5
# Clean existing example products first
php artisan shop:add-example-products --clean
```
This command creates:
- ✅ All 4 product types (simple, variable, grouped, external)
- ✅ Product categories
- ✅ Product attributes (material, size, color, etc.)
- ✅ Multiple pricing options (multi-currency, subscriptions)
- ✅ Example product actions (email notifications, stats updates)
- ✅ Variations for variable products
- ✅ Child products for grouped products
- ✅ Realistic data using Faker
### Reinstall Shop Tables
```bash
# With confirmation
php artisan shop:reinstall
# Force without confirmation
php artisan shop:reinstall --force
```
⚠️ **Warning:** This will delete all shop data!
## License
MIT License
## Support
For issues and questions, please use the [GitHub issue tracker](https://github.com/blax/laravel-shop/issues).

View File

@ -281,6 +281,7 @@ return new class extends Migration
$table->uuid('id')->primary(); $table->uuid('id')->primary();
$table->uuid('cart_id'); $table->uuid('cart_id');
$table->uuidMorphs('purchasable'); $table->uuidMorphs('purchasable');
$table->foreignUuid('product_id')->nullable()->constrained(config('shop.tables.products', 'products'))->nullOnDelete();
$table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete(); $table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete();
$table->foreignUuid('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete(); $table->foreignUuid('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete();
$table->integer('quantity')->default(1); $table->integer('quantity')->default(1);

View File

@ -214,24 +214,28 @@ if ($product->isLowStock()) {
} }
``` ```
### Stock Reservations ### Stock Claims (Reservations)
```php ```php
use Blax\Shop\Models\ProductStock; use Blax\Shop\Models\ProductStock;
// Reserve stock temporarily // Claim stock temporarily (for bookings)
$reservation = $product->reserveStock( $claim = $product->claimStock(
quantity: 2, quantity: 2,
reference: $cart, reference: $cart,
until: now()->addMinutes(15), from: now(),
until: now()->addDays(3),
note: 'Cart reservation' note: 'Cart reservation'
); );
// Release reservation // Release claim
$reservation->update(['status' => 'completed']); $product->releaseStock($cart);
// Get active reservations // Get active claims
$reservations = $product->reservations()->get(); $claims = $product->stocks()
->where('type', 'claimed')
->where('status', 'pending')
->get();
``` ```
### Stock History ### Stock History
@ -399,25 +403,27 @@ use Blax\Shop\Models\ProductAction;
// Send email on purchase // Send email on purchase
ProductAction::create([ ProductAction::create([
'product_id' => $product->id, 'product_id' => $product->id,
'action_type' => 'SendWelcomeEmail', 'class' => \App\Jobs\SendWelcomeEmail::class,
'event' => 'purchased', 'events' => ['purchased'],
'parameters' => [ 'parameters' => [
'template' => 'welcome', 'template' => 'welcome',
'delay' => 0, 'delay' => 0,
], ],
'active' => true, 'active' => true,
'defer' => true,
'sort_order' => 1, 'sort_order' => 1,
]); ]);
// Grant access on purchase // Grant access on purchase
ProductAction::create([ ProductAction::create([
'product_id' => $product->id, 'product_id' => $product->id,
'action_type' => 'GrantCourseAccess', 'class' => \App\Jobs\GrantCourseAccess::class,
'event' => 'purchased', 'events' => ['purchased'],
'parameters' => [ 'parameters' => [
'course_id' => 123, 'course_id' => 123,
], ],
'active' => true, 'active' => true,
'defer' => true,
'sort_order' => 2, 'sort_order' => 2,
]); ]);
``` ```

View File

@ -87,8 +87,17 @@ $user = auth()->user();
$product = Product::find($productId); $product = Product::find($productId);
try { try {
// For regular products
$cartItem = $user->addToCart($product, quantity: 1); $cartItem = $user->addToCart($product, quantity: 1);
// For booking products (requires dates)
$from = Carbon::parse('2025-01-15');
$until = Carbon::parse('2025-01-20');
$cartItem = $user->addToCart($product, quantity: 1, parameters: [
'from' => $from,
'until' => $until,
]);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'cart_item' => $cartItem, 'cart_item' => $cartItem,
@ -187,20 +196,23 @@ $stats = [
## Cart Checkout ## Cart Checkout
### Convert Cart to Purchases ### Checkout Cart
```php ```php
try { try {
$purchases = $user->checkoutCart(); // Get current cart
$cart = $user->currentCart();
// Checkout successful // Checkout (creates purchases and order)
// Cart items are now converted to completed purchases $cart->checkout();
// Cart is marked as converted
// Access the order
$order = $cart->order;
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'purchases' => $purchases, 'order' => $order,
'total_items' => $purchases->count(), 'order_number' => $order->order_number,
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
@ -209,28 +221,38 @@ try {
} }
``` ```
### What Happens During Checkout
1. **Validates Cart**
- Checks that cart is not empty
- Validates all items have required information
- For booking products: validates dates are set
2. **Claims Stock**
- Claims stock for booking/pool products
- Validates stock availability
3. **Creates Order**
- Generates order number
- Creates Order record linked to cart
- Copies cart total to order amounts
4. **Creates Purchases**
- Creates ProductPurchase records for each cart item
- Links purchases to order
5. **Converts Cart**
- Marks cart as CONVERTED
- Sets `converted_at` timestamp
### Important Notes ### Important Notes
- Checkout validates stock availability for all items - Stock is claimed at checkout time (not add-to-cart time for bookings)
- Creates `ProductPurchase` records for each cart item - Cart items remain in database but are marked as converted
- Decreases stock for each item - Order is created with PENDING status by default
- Triggers product actions
- Marks cart as converted (`converted_at` timestamp)
- Removes cart items after successful checkout
## Purchase History ## Purchase History
### Check if User Purchased Product
```php
$product = Product::find($productId);
if ($user->hasPurchased($product)) {
// User has purchased this product
echo "You own this product!";
}
```
### Get All Purchases ### Get All Purchases
```php ```php
@ -247,55 +269,226 @@ $productPurchases = $user->purchases()
->get(); ->get();
``` ```
### Purchase Statistics ## Order Management
### Get All Orders
```php ```php
$stats = $user->getPurchaseStats(); // Get all orders
$orders = $user->orders()->get();
// Returns: // Get orders with specific status
// [ use Blax\Shop\Enums\OrderStatus;
// 'total_purchases' => 15,
// 'total_spent' => 450.00, $pendingOrders = $user->pendingOrders()->get();
// 'total_items' => 23, $processingOrders = $user->processingOrders()->get();
// 'cart_items' => 2, $completedOrders = $user->completedOrders()->get();
// 'cart_total' => 89.99,
// ] // Get active orders (not completed/cancelled/refunded)
$activeOrders = $user->activeOrders()->get();
```
### Order Status Flow
Orders progress through these statuses:
1. **PENDING** - Order received but awaiting payment confirmation
2. **PROCESSING** - Payment received and order is being processed
3. **ON_HOLD** - Order on hold, awaiting further action
4. **IN_PREPARATION** - Order being prepared (packing, manufacturing)
5. **READY_FOR_PICKUP** - Order ready for pickup (for local pickup orders)
6. **SHIPPED** - Order has been shipped and is in transit
7. **DELIVERED** - Order delivered to customer
8. **COMPLETED** - Order complete, all actions fulfilled
9. **CANCELLED** - Order was cancelled
10. **REFUNDED** - Order was refunded
11. **FAILED** - Payment or processing failed
### Get Order by Number
```php
$order = $user->findOrderByNumber('ORD-2025-0001');
if ($order) {
echo "Order found: {$order->order_number}";
}
```
### Order Details
```php
$order = Order::find($orderId);
// Order properties
$order->order_number; // Unique order number
$order->status; // OrderStatus enum
$order->amount_total; // Total amount (in cents)
$order->amount_paid; // Amount paid (in cents)
$order->amount_subtotal; // Subtotal before tax/shipping
$order->amount_tax; // Tax amount
$order->amount_shipping; // Shipping cost
$order->amount_discount; // Discount applied
$order->amount_refunded; // Amount refunded
// Dates
$order->created_at; // When order was created
$order->paid_at; // When payment was received
$order->shipped_at; // When order was shipped
$order->delivered_at; // When order was delivered
$order->completed_at; // When order was completed
$order->cancelled_at; // When order was cancelled
$order->refunded_at; // When order was refunded
// Additional info
$order->payment_method; // Payment method used
$order->payment_provider; // Payment provider (e.g., 'stripe')
$order->payment_reference; // Provider reference ID
$order->billing_address; // Billing address object
$order->shipping_address; // Shipping address object
$order->customer_note; // Customer's note
$order->internal_note; // Internal staff note
```
### Order Relationships
```php
// Get order customer
$customer = $order->customer;
// Get order purchases (line items)
$purchases = $order->purchases()->get();
// Get original cart
$cart = $order->cart;
// Get order notes
$notes = $order->notes()->get();
```
### Order Statistics
```php
// Total spent across all orders
$totalSpent = $user->total_spent; // Accessor in cents
// Number of orders
$orderCount = $user->order_count;
// Number of completed orders
$completedCount = $user->completed_order_count;
// Check if user has any orders
if ($user->hasOrders()) {
echo "Customer has placed orders";
}
// Check if user has active orders
if ($user->hasActiveOrders()) {
echo "Customer has orders in progress";
}
// Get latest order
$latestOrder = $user->latestOrder();
```
### Filter Orders by Date
```php
$from = Carbon::parse('2025-01-01');
$to = Carbon::parse('2025-12-31');
$ordersThisYear = $user->ordersBetween($from, $to)->get();
```
### Order Payment Status
```php
// Check if order is paid
if ($order->is_paid) {
echo "Order has been paid";
}
// Check if fully paid
if ($order->is_fully_paid) {
echo "Order is fully paid";
}
// Get outstanding amount
$outstanding = $order->amount_outstanding; // In cents
```
### Update Order Status
```php
use Blax\Shop\Enums\OrderStatus;
// Update order status
$order->update(['status' => OrderStatus::PROCESSING]);
// Mark as shipped
$order->update([
'status' => OrderStatus::SHIPPED,
'shipped_at' => now(),
]);
// Mark as delivered
$order->update([
'status' => OrderStatus::DELIVERED,
'delivered_at' => now(),
]);
// Mark as completed
$order->update([
'status' => OrderStatus::COMPLETED,
'completed_at' => now(),
]);
```
### Add Order Notes
```php
use Blax\Shop\Models\OrderNote;
// Add customer-visible note
OrderNote::create([
'order_id' => $order->id,
'content' => 'Your order has been shipped!',
'is_customer_note' => true,
]);
// Add internal note
OrderNote::create([
'order_id' => $order->id,
'content' => 'Customer requested gift wrapping',
'is_customer_note' => false,
]);
// Get all notes
$allNotes = $order->notes()->get();
// Get customer-visible notes only
$customerNotes = $order->notes()->where('is_customer_note', true)->get();
``` ```
## Refunds ## Refunds
### Refund a Purchase ### Refund an Order
```php ```php
$purchase = ProductPurchase::find($purchaseId); use Blax\Shop\Enums\OrderStatus;
try { $order = Order::find($orderId);
$success = $user->refundPurchase($purchase);
if ($success) { // Mark order as refunded
// Refund successful $order->update([
// Stock has been returned 'status' => OrderStatus::REFUNDED,
// Purchase status changed to 'refunded' 'refunded_at' => now(),
// Product 'refunded' actions triggered 'amount_refunded' => $order->amount_total,
]);
return response()->json([ // Stock will be released back from associated purchases
'success' => true,
'message' => 'Purchase refunded successfully',
]);
}
} catch (\Exception $e) {
return response()->json([
'error' => $e->getMessage()
], 400);
}
``` ```
### Important Notes
- Only completed purchases can be refunded
- Stock is automatically returned to inventory
- Product actions with event 'refunded' are triggered
## Cart Model ## Cart Model
### Get Current Cart ### Get Current Cart
@ -321,8 +514,8 @@ $cart->last_activity_at; // Last activity timestamp
// Get cart items // Get cart items
$items = $cart->items()->get(); $items = $cart->items()->get();
// Get cart purchases (if converted) // Get cart order (if converted)
$purchases = $cart->purchases()->get(); $order = $cart->order;
// Get cart customer (user) // Get cart customer (user)
$customer = $cart->customer; $customer = $cart->customer;
@ -369,7 +562,7 @@ $cartItem = $cart->addToCart(
```php ```php
$purchase = ProductPurchase::find($purchaseId); $purchase = ProductPurchase::find($purchaseId);
$purchase->status; // cart, pending, unpaid, completed, refunded $purchase->status; // pending, unpaid, completed, refunded, failed
$purchase->cart_id; // Associated cart ID $purchase->cart_id; // Associated cart ID
$purchase->price_id; // Associated price ID $purchase->price_id; // Associated price ID
$purchase->purchasable_id; // Product ID $purchase->purchasable_id; // Product ID
@ -377,9 +570,11 @@ $purchase->purchasable_type; // Product class
$purchase->purchaser_id; // User ID $purchase->purchaser_id; // User ID
$purchase->purchaser_type; // User class $purchase->purchaser_type; // User class
$purchase->quantity; // Quantity purchased $purchase->quantity; // Quantity purchased
$purchase->amount; // Total amount $purchase->amount; // Total amount (in cents)
$purchase->amount_paid; // Amount paid $purchase->amount_paid; // Amount paid (in cents)
$purchase->charge_id; // Payment charge ID $purchase->charge_id; // Payment charge ID
$purchase->from; // Booking start date (for bookings)
$purchase->until; // Booking end date (for bookings)
$purchase->meta; // Additional metadata $purchase->meta; // Additional metadata
``` ```
@ -391,35 +586,44 @@ $product = $purchase->purchasable;
// Get purchaser (user) // Get purchaser (user)
$user = $purchase->purchaser; $user = $purchase->purchaser;
// Get associated cart item
$cartItem = $purchase->cartItem;
// Get associated order
$order = $purchase->order;
``` ```
### Purchase Scopes ### Purchase Scopes
```php ```php
// Get purchases in cart use Blax\Shop\Enums\PurchaseStatus;
$cartPurchases = ProductPurchase::inCart()->get();
// Get completed purchases // Get completed purchases
$completed = ProductPurchase::completed()->get(); $completed = ProductPurchase::where('status', PurchaseStatus::COMPLETED)->get();
// Get purchases from specific cart // Get pending purchases
$cartPurchases = ProductPurchase::fromCart($cartId)->get(); $pending = ProductPurchase::where('status', PurchaseStatus::PENDING)->get();
``` ```
## Stock Reservations ## Stock Claims
When adding products to cart, stock is automatically reserved: When adding booking products to cart, stock is claimed at checkout time:
```php ```php
// Stock is reserved when adding to cart // For booking products, stock is NOT claimed when adding to cart
$cartItem = $user->addToCart($product, quantity: 2); $cartItem = $user->addToCart($bookingProduct, quantity: 1, parameters: [
'from' => Carbon::parse('2025-01-15'),
'until' => Carbon::parse('2025-01-20'),
]);
// Reservation is created automatically // Stock is validated and claimed during checkout
// It expires after configured time (default: 15 minutes) $cart = $user->currentCart();
// Stock is released back when: $cart->checkout(); // Claims stock at this point
// - Reservation expires
// - Cart item is removed // For regular products, stock is claimed immediately when adding to cart
// - Cart is abandoned $cartItem = $user->addToCart($regularProduct, quantity: 2);
// Stock is claimed immediately for non-booking products
``` ```
## Error Handling ## Error Handling
@ -491,10 +695,14 @@ Route::post('/checkout', function () {
$user = auth()->user(); $user = auth()->user();
try { try {
$purchases = $user->checkoutCart(); $cart = $user->currentCart();
$cart->checkout();
return redirect()->route('orders.success') // Access the created order
->with('success', 'Order placed successfully!'); $order = $cart->order;
return redirect()->route('orders.success', ['order' => $order->id])
->with('success', "Order {$order->order_number} placed successfully!");
} catch (\Exception $e) { } catch (\Exception $e) {
return redirect()->back()->with('error', $e->getMessage()); return redirect()->back()->with('error', $e->getMessage());
} }
@ -504,11 +712,25 @@ Route::post('/checkout', function () {
Route::get('/orders', function () { Route::get('/orders', function () {
$user = auth()->user(); $user = auth()->user();
$purchases = $user->completedPurchases() $orders = $user->orders()
->with('purchasable') ->with(['purchases.purchasable'])
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->get(); ->get();
return view('orders.index', compact('purchases')); return view('orders.index', compact('orders'));
});
// View specific order
Route::get('/orders/{order}', function (Order $order) {
$user = auth()->user();
// Ensure user owns this order
if ($order->customer_id !== $user->id) {
abort(403);
}
$order->load(['purchases.purchasable', 'notes']);
return view('orders.show', compact('order'));
}); });
``` ```

View File

@ -73,13 +73,16 @@ Redirect the user to the `url` to complete payment.
GET /api/shop/stripe/success?session_id={SESSION_ID}&cart_id={CART_ID} GET /api/shop/stripe/success?session_id={SESSION_ID}&cart_id={CART_ID}
``` ```
When payment is successful: When payment is successful (handled via webhook):
- Cart status is updated to `CONVERTED` - Cart status is updated to `CONVERTED`
- Cart's `converted_at` is set - Cart's `converted_at` is set
- ProductPurchases are updated with: - Order is created from the cart (if not already exists)
- Payment is recorded on the order
- ProductPurchases are created with:
- `status``COMPLETED` - `status``COMPLETED`
- `charge_id` → Stripe Payment Intent ID - `charge_id` → Stripe Payment Intent ID
- `amount_paid` → Amount from Stripe (in dollars, converted from cents) - `amount` and `amount_paid` → Amount from Stripe (in cents)
- Order status changes to `PROCESSING`
### Cancel URL ### Cancel URL
@ -101,13 +104,31 @@ POST /api/shop/stripe/webhook
The webhook handler processes the following Stripe events: The webhook handler processes the following Stripe events:
- `checkout.session.completed` - Updates cart to converted, updates purchases **Checkout Session Events:**
- `checkout.session.completed` - Converts cart, creates order if needed, records payment
- `checkout.session.async_payment_succeeded` - Same as completed - `checkout.session.async_payment_succeeded` - Same as completed
- `checkout.session.async_payment_failed` - Logs failure - `checkout.session.async_payment_failed` - Marks order as failed if exists
- `charge.succeeded` - Updates purchases with charge info - `checkout.session.expired` - Adds note to order
- `charge.failed` - Marks purchases as `FAILED`
- `payment_intent.succeeded` - Updates purchases **Charge Events:**
- `payment_intent.payment_failed` - Marks purchases as `FAILED` - `charge.succeeded` - Updates purchases with charge info, records payment on order
- `charge.failed` - Marks purchases as `FAILED`, adds note to order
- `charge.refunded` - Records refund on order
- `charge.dispute.created` - Puts order on hold, adds dispute note
- `charge.dispute.closed` - Updates order based on dispute outcome
**Payment Intent Events:**
- `payment_intent.succeeded` - Records payment on order
- `payment_intent.payment_failed` - Adds failure note to order
- `payment_intent.canceled` - Adds cancellation note
**Refund Events:**
- `refund.created` - Records refund on order
- `refund.updated` - Updates refund information
**Invoice Events** (for subscriptions):
- `invoice.payment_succeeded` - Handles subscription payments
- `invoice.payment_failed` - Handles failed subscription payments
### Configuring Webhook in Stripe ### Configuring Webhook in Stripe
@ -148,14 +169,30 @@ Route::post('custom/stripe/webhook', [StripeWebhookController::class, 'handleWeb
->name('shop.stripe.webhook'); ->name('shop.stripe.webhook');
``` ```
## ProductPurchase Updates ## ProductPurchase and Order Updates
The webhook handler automatically updates ProductPurchase records with charge information if the columns exist: The webhook handler automatically updates ProductPurchase records and creates/updates Order records:
### Purchase Updates
- `charge_id` - Stripe Payment Intent ID - `charge_id` - Stripe Payment Intent ID
- `amount_paid` - Amount paid in dollars - `amount` - Amount in cents
- `amount_paid` - Amount paid in cents
- `status` - Updated to COMPLETED, FAILED, or REFUNDED based on event
These fields are automatically populated from the fillable array on the ProductPurchase model. ### Order Creation and Updates
When a checkout session is completed:
1. Cart is marked as CONVERTED
2. Order is created from cart (if doesn't exist) via `Order::createFromCart($cart)`
3. Payment is recorded on order via `$order->recordPayment($amount, $reference, 'stripe', 'stripe')`
4. Order status is updated to PROCESSING when payment is successful
5. OrderNote records are created for payment events
These fields are automatically populated:
- `payment_reference` - Stripe Payment Intent ID
- `payment_method` - 'stripe'
- `payment_provider` - 'stripe'
- `amount_paid` - Amount paid in cents
- `paid_at` - Timestamp when payment was received
## Error Handling ## Error Handling

View File

@ -664,4 +664,3 @@ $products = Product::with([
- [Pool Products](./ProductTypes/02-pool-products.md) - POOL/SINGLE relations in detail - [Pool Products](./ProductTypes/02-pool-products.md) - POOL/SINGLE relations in detail
- [Product Types](./ProductTypes/) - Understanding different product types - [Product Types](./ProductTypes/) - Understanding different product types
- [Stock Management](./06-stock-management.md) - How stock works with relations

View File

@ -316,4 +316,3 @@ $available = $product->getAvailableStock($date);
- [Pool Products](./02-pool-products.md) - Managing groups of booking products - [Pool Products](./02-pool-products.md) - Managing groups of booking products
- [Product Relations](../05-product-relations.md) - How products relate to each other - [Product Relations](../05-product-relations.md) - How products relate to each other
- [Stock Management](../06-stock-management.md) - Detailed stock system documentation

View File

@ -359,33 +359,77 @@ if (!$parkingPool->validatePoolConfiguration()['valid']) {
## Cart Integration ## Cart Integration
### Adding Pool to Cart ### Cart Item Tracking
When a pool product is added to cart, the system tracks which specific single item is allocated:
```php ```php
$from = Carbon::parse('2025-01-15'); $from = Carbon::parse('2025-01-15');
$until = Carbon::parse('2025-01-17'); // 2 days $until = Carbon::parse('2025-01-17');
$cartItem = $cart->addToCart($parkingPool, $quantity = 1, [], $from, $until); $cartItem = $cart->addToCart($parkingPool, $quantity = 1, [], $from, $until);
// Cart item properties: // Cart item properties:
// - purchasable: Pool Product // - purchasable_id: Pool Product ID
// - purchasable_type: Product::class
// - product_id: Allocated Single Item ID (NEW!)
// - quantity: 1 // - quantity: 1
// - from: 2025-01-15 // - from: 2025-01-15
// - until: 2025-01-17 // - until: 2025-01-17
// - price: (unit_amount × 2 days) // - price: (unit_amount × 2 days)
// - meta->claimed_single_items: [spot_id]
``` ```
### Viewing Claimed Items ### Product ID Column
The `product_id` column in cart_items table stores the specific single item allocated from the pool:
```php ```php
$meta = $cartItem->getMeta(); $cartItem->product_id; // ID of the allocated single item
$claimedItemIds = $meta->claimed_single_items ?? []; $cartItem->purchasable_id; // ID of the pool product
$cartItem->purchasable; // The pool product itself
$cartItem->product; // The allocated single item
// Load the actual products // Get the effective product (allocated single or purchasable)
$claimedItems = Product::whereIn('id', $claimedItemIds)->get(); $effectiveProduct = $cartItem->getEffectiveProduct();
``` ```
### Viewing Allocated Items
```php
// Get the allocated single item
$allocatedSingle = Product::find($cartItem->product_id);
// Or use the relationship
$allocatedSingle = $cartItem->product;
// Pool product is still accessible
$poolProduct = $cartItem->purchasable;
```
### Date Changes and Reallocation
When cart dates change, the system automatically reallocates pool items to optimize pricing:
```php
// Update cart dates
$cart->setDates($newFrom, $newUntil);
// Behind the scenes:
// 1. System calls reallocatePoolItems($newFrom, $newUntil)
// 2. For each pool item, finds available singles for new dates
// 3. Applies pricing strategy (LOWEST, HIGHEST, AVERAGE)
// 4. Reallocates to better-priced singles if available
// 5. Updates cart_item.product_id to new allocation
// 6. Recalculates prices based on new dates
```
The `reallocatePoolItems()` method:
- Checks availability of all single items for the new dates
- Applies the pool's pricing strategy
- Reassigns cart items to optimal single items
- Updates `product_id` column with new allocation
- Marks items as unavailable if no singles are available for the period
### Removing from Cart ### Removing from Cart
```php ```php
@ -393,8 +437,8 @@ $cartItem->delete();
``` ```
**What happens:** **What happens:**
1. System finds claimed single items from metadata 1. System finds allocated single item from `product_id` column
2. Releases claims on each single item 2. Releases claims on the single item
3. Stock becomes available again 3. Stock becomes available again
## Advanced Usage ## Advanced Usage
@ -642,6 +686,22 @@ $pool->attachSingleItems($itemIds);
// - Items → Pool (POOL) // - Items → Pool (POOL)
``` ```
### Wrong Single Item Allocated
**Cause:** Pricing strategy or date-based availability issue
**Solution:**
```php
// Force reallocation by updating cart dates
$cart->setDates($from, $until, $overwrite = true);
// Or manually check which single was allocated
$allocatedSingle = $cartItem->product;
// Verify pricing strategy is correct
$strategy = $pool->getPricingStrategy();
```
## Performance Considerations ## Performance Considerations
### 1. Lazy Loading ### 1. Lazy Loading
@ -685,5 +745,3 @@ $pools->each(function($pool) {
- [Booking Products](./01-booking-products.md) - Understanding single items in pools - [Booking Products](./01-booking-products.md) - Understanding single items in pools
- [Product Relations](../05-product-relations.md) - Relation system details - [Product Relations](../05-product-relations.md) - Relation system details
- [Pricing Strategies](../07-pricing-strategies.md) - In-depth pricing documentation
- [Stock Management](../06-stock-management.md) - How stock system works

View File

@ -584,8 +584,11 @@ class ShopAddExampleProducts extends Command
$prices = $productData['variation_prices'] ?? []; $prices = $productData['variation_prices'] ?? [];
foreach ($variations as $index => $variation) { foreach ($variations as $index => $variation) {
$variationName = ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation;
$variationProduct = Product::create([ $variationProduct = Product::create([
'slug' => $product->slug . '-' . \Illuminate\Support\Str::slug($variation), 'slug' => $product->slug . '-' . \Illuminate\Support\Str::slug($variation),
'name' => $variationName,
'sku' => $product->sku . '-' . strtoupper(substr($variation, 0, 3)), 'sku' => $product->sku . '-' . strtoupper(substr($variation, 0, 3)),
'type' => 'simple', 'type' => 'simple',
'parent_id' => $product->id, 'parent_id' => $product->id,
@ -596,7 +599,7 @@ class ShopAddExampleProducts extends Command
'meta' => ['variation' => $variation, 'example' => true], 'meta' => ['variation' => $variation, 'example' => true],
]); ]);
$variationProduct->setLocalized('name', ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation, null, true); $variationProduct->setLocalized('name', $variationName, null, true);
$variationAmount = $prices[$index] ?? ($basePrice + ($index * 500)); $variationAmount = $prices[$index] ?? ($basePrice + ($index * 500));
$variationProduct->prices()->create([ $variationProduct->prices()->create([
@ -627,8 +630,11 @@ class ShopAddExampleProducts extends Command
} }
foreach ($productData['grouped_items'] as $i => $item) { foreach ($productData['grouped_items'] as $i => $item) {
$itemName = $item['name'];
$childProduct = Product::create([ $childProduct = Product::create([
'slug' => $product->slug . '-item-' . ($i + 1), 'slug' => $product->slug . '-item-' . ($i + 1),
'name' => $itemName,
'sku' => $item['sku'], 'sku' => $item['sku'],
'type' => 'simple', 'type' => 'simple',
'parent_id' => $product->id, 'parent_id' => $product->id,
@ -639,7 +645,7 @@ class ShopAddExampleProducts extends Command
'meta' => ['grouped_item' => true, 'example' => true], 'meta' => ['grouped_item' => true, 'example' => true],
]); ]);
$childProduct->setLocalized('name', $item['name'], null, true); $childProduct->setLocalized('name', $itemName, null, true);
$childProduct->prices()->create([ $childProduct->prices()->create([
'name' => 'Default', 'name' => 'Default',
@ -662,9 +668,11 @@ class ShopAddExampleProducts extends Command
$parkingIds = []; $parkingIds = [];
foreach ($productData['pool_items'] as $i => $item) { foreach ($productData['pool_items'] as $i => $item) {
$itemName = $item['name'];
$parking = Product::create([ $parking = Product::create([
'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($item['name']), 'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName),
'name' => $item['name'], 'name' => $itemName,
'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT), 'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT),
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'status' => ProductStatus::PUBLISHED, 'status' => ProductStatus::PUBLISHED,
@ -675,10 +683,15 @@ class ShopAddExampleProducts extends Command
'meta' => ['example' => true, 'pool_item' => true, 'parent_pool' => $pool->name], 'meta' => ['example' => true, 'pool_item' => true, 'parent_pool' => $pool->name],
]); ]);
// Set localized name for consistency with other products
$parking->setLocalized('name', $itemName, null, true);
// Set stock for the parking spot // Set stock for the parking spot
$parking->increaseStock($item['stock']); $parking->increaseStock($item['stock']);
// Create price for individual parking spot // Create price for individual parking spot
// Note: If price is not provided, the pool's fallback price will be used during checkout
if (!empty($item['price'])) {
$parking->prices()->create([ $parking->prices()->create([
'name' => 'Default', 'name' => 'Default',
'type' => 'one_time', 'type' => 'one_time',
@ -689,6 +702,7 @@ class ShopAddExampleProducts extends Command
'billing_scheme' => 'per_unit', 'billing_scheme' => 'per_unit',
'meta' => ['example' => true], 'meta' => ['example' => true],
]); ]);
}
$parkingIds[] = $parking->id; $parkingIds[] = $parking->id;
} }

View File

@ -141,9 +141,21 @@ class StripeWebhookController
]); ]);
} }
// Record payment on the associated order // Get or create order from the cart
$order = $cart->order; $order = $cart->order;
if ($order) { if (!$order) {
// Create order from the converted cart
$order = Order::createFromCart($cart);
Log::info('Order created from Stripe checkout session', [
'order_id' => $order->id,
'order_number' => $order->order_number,
'cart_id' => $cart->id,
'session_id' => $session->id,
]);
}
// Record payment on the order
$amountPaid = (int) (($session->amount_total ?? 0) / 100); $amountPaid = (int) (($session->amount_total ?? 0) / 100);
$currency = strtoupper($session->currency ?? $order->currency ?? 'USD'); $currency = strtoupper($session->currency ?? $order->currency ?? 'USD');
@ -167,7 +179,6 @@ class StripeWebhookController
'amount' => $amountPaid, 'amount' => $amountPaid,
'currency' => $currency, 'currency' => $currency,
]); ]);
}
return true; return true;
} }

View File

@ -573,8 +573,7 @@ class Cart extends Model
// For pool products, check if allocated by reallocatePoolItems // For pool products, check if allocated by reallocatePoolItems
if ($product instanceof Product && $product->isPool()) { if ($product instanceof Product && $product->isPool()) {
$meta = $item->getMeta(); $allocatedSingleItemId = $item->product_id;
$allocatedSingleItemId = $meta->allocated_single_item_id ?? null;
// If this item was NOT allocated (no single assigned), skip updateDates // If this item was NOT allocated (no single assigned), skip updateDates
// to preserve the null price set by reallocatePoolItems // to preserve the null price set by reallocatePoolItems
@ -702,13 +701,13 @@ class Cart extends Model
} }
// Clear allocation and set price to null to indicate unavailable // Clear allocation and set price to null to indicate unavailable
$cartItem->updateMetaKey('allocated_single_item_id', null);
$cartItem->updateMetaKey('allocated_single_item_name', null);
$cartItem->update([ $cartItem->update([
'product_id' => null,
'price' => null, 'price' => null,
'subtotal' => null, 'subtotal' => null,
'unit_amount' => null, 'unit_amount' => null,
]); ]);
$cartItem->updateMetaKey('allocated_single_item_name', null);
} }
continue; continue;
} }
@ -747,12 +746,16 @@ class Cart extends Model
if ($remainingFromSingle >= $neededQty) { if ($remainingFromSingle >= $neededQty) {
// This single can accommodate the cart item's full quantity // This single can accommodate the cart item's full quantity
$cartItem->updateMetaKey('allocated_single_item_id', $single->id); // Update product_id to track the allocated single item
$updates = ['product_id' => $single->id];
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
$updates['price_id'] = $singleInfo['price_id'];
}
$cartItem->update($updates);
$cartItem->updateMetaKey('allocated_single_item_name', $single->name); $cartItem->updateMetaKey('allocated_single_item_name', $single->name);
// Update price_id if changed // Legacy: update price_id if changed (now handled in the update above)
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) { if (false) {
$cartItem->update(['price_id' => $singleInfo['price_id']]);
} }
// Track usage // Track usage
@ -784,17 +787,17 @@ class Cart extends Model
// Update the original cart item with reduced quantity // Update the original cart item with reduced quantity
// Also update subtotal to match the new quantity // Also update subtotal to match the new quantity
$newSubtotal = $cartItem->price * $qtyToAllocate; $newSubtotal = $cartItem->price * $qtyToAllocate;
$cartItem->update([ $updates = [
'quantity' => $qtyToAllocate, 'quantity' => $qtyToAllocate,
'subtotal' => $newSubtotal, 'subtotal' => $newSubtotal,
]); 'product_id' => $single->id,
$cartItem->refresh(); // Ensure model reflects database state ];
$cartItem->updateMetaKey('allocated_single_item_id', $single->id);
$cartItem->updateMetaKey('allocated_single_item_name', $single->name);
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) { if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
$cartItem->update(['price_id' => $singleInfo['price_id']]); $updates['price_id'] = $singleInfo['price_id'];
} }
$cartItem->update($updates);
$cartItem->refresh(); // Ensure model reflects database state
$cartItem->updateMetaKey('allocated_single_item_name', $single->name);
$firstAllocation = false; $firstAllocation = false;
} else { } else {
@ -814,6 +817,7 @@ class Cart extends Model
$newCartItem = $this->items()->create([ $newCartItem = $this->items()->create([
'purchasable_id' => $cartItem->purchasable_id, 'purchasable_id' => $cartItem->purchasable_id,
'purchasable_type' => $cartItem->purchasable_type, 'purchasable_type' => $cartItem->purchasable_type,
'product_id' => $single->id,
'price_id' => $priceModel?->id, 'price_id' => $priceModel?->id,
'quantity' => $qtyToAllocate, 'quantity' => $qtyToAllocate,
'price' => $pricePerUnit, 'price' => $pricePerUnit,
@ -825,7 +829,6 @@ class Cart extends Model
'until' => $until, 'until' => $until,
]); ]);
$newCartItem->updateMetaKey('allocated_single_item_id', $single->id);
$newCartItem->updateMetaKey('allocated_single_item_name', $single->name); $newCartItem->updateMetaKey('allocated_single_item_name', $single->name);
} }
@ -838,13 +841,13 @@ class Cart extends Model
if ($remainingQty > 0) { if ($remainingQty > 0) {
if ($firstAllocation) { if ($firstAllocation) {
// Couldn't allocate anything - mark as unavailable // Couldn't allocate anything - mark as unavailable
$cartItem->updateMetaKey('allocated_single_item_id', null);
$cartItem->updateMetaKey('allocated_single_item_name', null);
$cartItem->update([ $cartItem->update([
'product_id' => null,
'price' => null, 'price' => null,
'subtotal' => null, 'subtotal' => null,
'unit_amount' => null, 'unit_amount' => null,
]); ]);
$cartItem->updateMetaKey('allocated_single_item_name', null);
} else { } else {
// Partial allocation - the cart item was already updated with what we could allocate // Partial allocation - the cart item was already updated with what we could allocate
// The remaining quantity is lost (over-capacity) // The remaining quantity is lost (over-capacity)
@ -1219,9 +1222,8 @@ class Cart extends Model
$expectedPrice = $poolItemData['price'] ?? null; $expectedPrice = $poolItemData['price'] ?? null;
$expectedSingleItemId = $poolItemData['item']?->id ?? null; $expectedSingleItemId = $poolItemData['item']?->id ?? null;
// Get the allocated single item ID from the existing cart item's meta // Get the allocated single item ID from the cart item's product_id column
$existingMeta = $item->getMeta(); $existingAllocatedItemId = $item->product_id;
$existingAllocatedItemId = $existingMeta->allocated_single_item_id ?? null;
// Only merge if: // Only merge if:
// 1. price_id matches (same price source) // 1. price_id matches (same price source)
@ -1272,11 +1274,7 @@ class Cart extends Model
$inCart = $this->items() $inCart = $this->items()
->where('purchasable_id', $cartable->getKey()) ->where('purchasable_id', $cartable->getKey())
->where('purchasable_type', get_class($cartable)) ->where('purchasable_type', get_class($cartable))
->get() ->where('product_id', $single->id)
->filter(function ($item) use ($single) {
$meta = $item->getMeta();
return isset($meta->allocated_single_item_id) && $meta->allocated_single_item_id == $single->id;
})
->sum('quantity'); ->sum('quantity');
if ($available === PHP_INT_MAX || $inCart < $available) { if ($available === PHP_INT_MAX || $inCart < $available) {
@ -1360,6 +1358,7 @@ class Cart extends Model
$cartItem = $this->items()->create([ $cartItem = $this->items()->create([
'purchasable_id' => $cartable->getKey(), 'purchasable_id' => $cartable->getKey(),
'purchasable_type' => get_class($cartable), 'purchasable_type' => get_class($cartable),
'product_id' => ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) ? $poolSingleItem->id : null,
'price_id' => $priceId, 'price_id' => $priceId,
'quantity' => $quantity, 'quantity' => $quantity,
'price' => $pricePerUnit, // Price per unit for the period 'price' => $pricePerUnit, // Price per unit for the period
@ -1371,9 +1370,8 @@ class Cart extends Model
'until' => ($is_booking) ? $until : null, 'until' => ($is_booking) ? $until : null,
]); ]);
// For pool products, store which single item is being used in meta // For pool products, store the single item name in meta for display purposes
if ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) { if ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) {
$cartItem->updateMetaKey('allocated_single_item_id', $poolSingleItem->id);
$cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name); $cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name);
} }
@ -1808,7 +1806,7 @@ class Cart extends Model
* d) If the product is a pool: * d) If the product is a pool:
* - If the pool contains booking single items, a timespan is required. * - If the pool contains booking single items, a timespan is required.
* - When a timespan exists and booking singles are used, claim stock: * - When a timespan exists and booking singles are used, claim stock:
* - Use a pre-allocated single item from item meta (`allocated_single_item_id`) when present. * - Use a pre-allocated single item from the `product_id` column when present.
* - Otherwise call the pool stock claiming logic (`claimPoolStock`). * - Otherwise call the pool stock claiming logic (`claimPoolStock`).
* - Persist claimed single-item IDs into cart item meta (`claimed_single_items`). * - Persist claimed single-item IDs into cart item meta (`claimed_single_items`).
* e) If the product is a non-pool booking product, require a timespan. * e) If the product is a non-pool booking product, require a timespan.
@ -1885,13 +1883,12 @@ class Cart extends Model
// If pool has timespan and has booking single items, claim stock from single items // If pool has timespan and has booking single items, claim stock from single items
if ($from && $until && $product->hasBookingSingleItems()) { if ($from && $until && $product->hasBookingSingleItems()) {
try { try {
// Check if we have pre-allocated single items from reallocation // Check if we have pre-allocated single items from product_id column
$meta = $item->getMeta(); $allocatedSingleId = $item->product_id;
$allocatedSingleId = $meta->allocated_single_item_id ?? null;
if ($allocatedSingleId) { if ($allocatedSingleId) {
// Use the pre-allocated single item // Use the pre-allocated single item from product_id
$singleItem = Product::find($allocatedSingleId); $singleItem = $item->product;
if (!$singleItem) { if (!$singleItem) {
throw new \Exception("Allocated single item not found: {$allocatedSingleId}"); throw new \Exception("Allocated single item not found: {$allocatedSingleId}");
} }

View File

@ -18,6 +18,7 @@ class CartItem extends Model
'cart_id', 'cart_id',
'purchasable_id', 'purchasable_id',
'purchasable_type', 'purchasable_type',
'product_id',
'price_id', 'price_id',
'quantity', 'quantity',
'price', 'price',
@ -96,10 +97,28 @@ class CartItem extends Model
); );
} }
public function product(): BelongsTo|null /**
* Get the actual product being purchased.
* For pool products, this is the single item allocated.
* For regular products, this returns the purchasable product itself.
*/
public function product(): BelongsTo
{ {
if ($this->purchasable_type === config('shop.models.product', Product::class)) { return $this->belongsTo(config('shop.models.product', Product::class), 'product_id');
return $this->belongsTo(config('shop.models.product'), 'purchasable_id'); }
/**
* Get the effective product - either the allocated product_id or the purchasable.
* This is useful for getting the actual product when product_id may be null.
*/
public function getEffectiveProduct(): ?Product
{
if ($this->product_id) {
return $this->product;
}
if ($this->purchasable instanceof Product) {
return $this->purchasable;
} }
return null; return null;
@ -482,12 +501,12 @@ class CartItem extends Model
// For pool products with an allocated single, use the allocated single's price // For pool products with an allocated single, use the allocated single's price
// This ensures consistency when reallocatePoolItems has already assigned a specific single // This ensures consistency when reallocatePoolItems has already assigned a specific single
$meta = $this->getMeta(); // The product_id column stores the actual single product being purchased
$allocatedSingleItemId = $meta->allocated_single_item_id ?? null; $allocatedSingleItemId = $this->product_id;
if ($product->isPool() && $allocatedSingleItemId) { if ($product->isPool() && $allocatedSingleItemId) {
// Get the allocated single item // Get the allocated single item from the product_id column
$allocatedSingle = Product::find($allocatedSingleItemId); $allocatedSingle = $this->product;
if ($allocatedSingle) { if ($allocatedSingle) {
// Get price from the allocated single, with fallback to pool price // Get price from the allocated single, with fallback to pool price

View File

@ -786,7 +786,7 @@ trait MayBePoolProduct
} }
// Build usage map: track which single items have been allocated // Build usage map: track which single items have been allocated
// Use allocated_single_item_id from meta to track actual single item usage // Use product_id column to track actual single item allocation
// ONLY count items that overlap with the current booking period // ONLY count items that overlap with the current booking period
// Exclude the specified cart item (if updating dates on existing item) // Exclude the specified cart item (if updating dates on existing item)
$singleItemUsage = []; // item_id => quantity used $singleItemUsage = []; // item_id => quantity used
@ -819,8 +819,8 @@ trait MayBePoolProduct
} }
// else: no dates provided, count all items for progressive pricing // else: no dates provided, count all items for progressive pricing
$meta = $item->getMeta(); // Get the allocated single item ID from the product_id column
$allocatedItemId = $meta->allocated_single_item_id ?? null; $allocatedItemId = $item->product_id;
if ($allocatedItemId) { if ($allocatedItemId) {
$singleItemUsage[$allocatedItemId] = ($singleItemUsage[$allocatedItemId] ?? 0) + $item->quantity; $singleItemUsage[$allocatedItemId] = ($singleItemUsage[$allocatedItemId] ?? 0) + $item->quantity;

View File

@ -0,0 +1,545 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Workbench\App\Models\User;
use PHPUnit\Framework\Attributes\Test;
/**
* Tests for cart item validation when dates change and items become unavailable.
*
* Bug: When adjusting dates in cart, some cart items show null/0 price because they
* are not available for the new dates. But IsReadyToCheckout incorrectly returns true.
*
* Expected behavior:
* - setDates() should NOT throw - it should allow users to fiddle with dates
* - Items that become unavailable should have price = null
* - Items with null price should NOT be ready for checkout
* - Cart.isReadyForCheckout() should return false if any items are unavailable
* - Exception should only be thrown at checkout time, not when changing dates
*/
class CartItemAvailabilityValidationTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
auth()->login($this->user);
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
}
/**
* Create a pool with limited singles for testing
*/
protected function createPoolWithLimitedSingles(int $numSingles = 3): Product
{
$pool = Product::factory()
->withPrices(1, 5000)
->create([
'name' => 'Limited Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$pool->setPoolPricingStrategy('lowest');
// Create singles with 1 stock each
for ($i = 1; $i <= $numSingles; $i++) {
$single = Product::factory()
->withStocks(1)
->withPrices(1, 5000)
->create([
'name' => "Single {$i}",
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$pool->attachSingleItems([$single->id]);
}
return $pool;
}
#[Test]
public function cart_item_with_null_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually set one item's price to null to simulate unavailable item
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
$item->refresh();
// Item with null price should NOT be ready for checkout
$this->assertNull($item->price);
$this->assertFalse($item->is_ready_to_checkout, 'Item with null price should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with null-price item should not be ready');
}
#[Test]
public function cart_item_with_zero_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually set one item's price to 0 to simulate unavailable item
$item = $this->cart->items()->first();
$item->update(['price' => 0, 'subtotal' => 0]);
$item->refresh();
// Item with 0 price should NOT be ready for checkout
$this->assertEquals(0, $item->price);
$this->assertFalse($item->is_ready_to_checkout, 'Item with price 0 should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with 0-price item should not be ready');
}
#[Test]
public function unallocated_pool_item_with_null_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates - all should be allocated
$this->cart->addToCart($pool, 3, [], $from, $until);
// Manually simulate an item becoming unavailable:
// - Remove allocation (product_id = null)
// - Set price to null (the real indicator of unavailability)
$item = $this->cart->items()->first();
$meta = $item->getMeta();
unset($meta->allocated_single_item_name);
$item->update([
'product_id' => null,
'meta' => json_encode($meta),
'price' => null,
'subtotal' => null,
]);
$item->refresh();
// Item with null price should NOT be ready for checkout
$this->assertFalse($item->is_ready_to_checkout, 'Item with null price should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with unavailable item should not be ready');
}
#[Test]
public function setDates_does_not_throw_when_items_become_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books all 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 3, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout(); // Claims the stock
// Our user adds items without dates (should work - we have 3 total capacity)
$this->cart->addToCart($pool, 3);
// All items should have prices > 0 initially
foreach ($this->cart->items as $item) {
$this->assertGreaterThan(0, $item->price, 'Item should have positive price initially');
}
// Now set dates that conflict with the booked period
// This should NOT throw - it should just mark items as unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->cart->refresh();
$this->cart->load('items');
// Cart should NOT be ready for checkout (items are unavailable)
$this->assertFalse(
$this->cart->is_ready_to_checkout,
'Cart should not be ready when items are unavailable for selected dates'
);
}
#[Test]
public function partial_availability_marks_some_items_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books 2 of 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 2, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout(); // Claims 2 singles
// Verify that only 1 single is available for the booked period
$available = $pool->getPoolMaxQuantity($bookedFrom, $bookedUntil);
$this->assertEquals(1, $available, 'Only 1 single should be available after booking 2');
// Our user adds 3 items without dates
$this->cart->addToCart($pool, 3);
$this->assertEquals(3, $this->cart->items()->sum('quantity'));
// Set dates where only 1 single is available
// Should NOT throw - just mark some items as unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->cart->refresh();
$this->cart->load('items');
// Check how many items are available vs unavailable
$availableItems = $this->cart->items->filter(
fn($item) =>
$item->price !== null && $item->price > 0
);
$unavailableItems = $this->cart->items->filter(
fn($item) =>
$item->price === null || $item->price <= 0
);
// Should have 1 available and 2 unavailable
$this->assertEquals(1, $availableItems->count(), 'Should have 1 available item');
$this->assertEquals(2, $unavailableItems->count(), 'Should have 2 unavailable items');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->is_ready_to_checkout, 'Cart with unavailable items should not be ready');
}
#[Test]
public function cart_item_without_allocated_single_for_pool_is_not_ready()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from, $until);
// Verify all items are allocated and ready
foreach ($this->cart->items as $item) {
$this->assertNotNull($item->product_id, 'Item should have product_id allocated');
$this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready');
}
// All items ready - cart is ready
$this->assertTrue($this->cart->fresh()->is_ready_to_checkout);
}
#[Test]
public function removing_unavailable_items_makes_cart_ready()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually make one item unavailable (price = null)
$unavailableItem = $this->cart->items()->first();
$unavailableItem->update(['price' => null, 'subtotal' => null]);
// Cart should NOT be ready
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
// Remove the unavailable item
$unavailableItem->delete();
// Set dates for remaining items
$from = now()->addDays(1);
$until = now()->addDays(2);
$this->cart->setDates($from, $until);
// Now cart should be ready
$this->assertTrue($this->cart->fresh()->is_ready_to_checkout);
}
#[Test]
public function getItemsRequiringAdjustments_includes_null_price_items()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from, $until);
// Make one item have null price
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
$this->cart->refresh();
$this->cart->load('items');
// Get items requiring adjustments
$itemsNeedingAdjustment = $this->cart->getItemsRequiringAdjustments();
// The null-price item should be in the list
$this->assertGreaterThanOrEqual(
1,
$itemsNeedingAdjustment->count(),
'Null price item should require adjustment'
);
// Check that it has 'unavailable' as the price adjustment reason
$nullPriceItem = $itemsNeedingAdjustment->first(fn($i) => $i->price === null);
$this->assertNotNull($nullPriceItem, 'Should find the null-price item');
$adjustments = $nullPriceItem->requiredAdjustments();
$this->assertArrayHasKey('price', $adjustments);
$this->assertEquals('unavailable', $adjustments['price']);
}
#[Test]
public function changing_dates_to_available_period_makes_items_available_again()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books all 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 3, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout();
// Our user adds 3 items without dates
$this->cart->addToCart($pool, 3);
// Set dates that conflict - items become unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
// Change to different dates where all singles are available
$availableFrom = now()->addDays(10);
$availableUntil = now()->addDays(11);
$this->cart->setDates($availableFrom, $availableUntil);
$this->cart->refresh();
$this->cart->load('items');
// All items should now have valid prices
foreach ($this->cart->items as $item) {
$this->assertNotNull($item->price, 'Item should have price after changing to available dates');
$this->assertGreaterThan(0, $item->price, 'Item should have positive price');
}
// Cart should be ready for checkout
$this->assertTrue($this->cart->is_ready_to_checkout, 'Cart should be ready after changing to available dates');
}
#[Test]
public function checkout_throws_when_items_are_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add items and make one unavailable
$this->cart->addToCart($pool, 3);
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
// Trying to checkout should throw CartItemMissingInformationException
// because the item has 'price' => 'unavailable' in requiredAdjustments()
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
$this->cart->checkout();
}
#[Test]
public function checkoutSessionLink_throws_when_items_have_null_price()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add items
$this->cart->addToCart($pool, 3, [], $from, $until);
// Manually make one unavailable
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
// checkoutSessionLink should throw because item is unavailable
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
$this->cart->checkoutSessionLink();
}
#[Test]
public function checkoutSessionLink_throws_when_items_have_zero_price()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add items
$this->cart->addToCart($pool, 3, [], $from, $until);
// Manually set price to 0 (should also be considered unavailable)
$item = $this->cart->items()->first();
$item->update(['price' => 0, 'subtotal' => 0]);
// checkoutSessionLink should throw because item has 0 price
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
$this->cart->checkoutSessionLink();
}
#[Test]
public function pool_items_maintain_consistent_pricing_after_date_changes()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from1 = now()->addDays(1);
$until1 = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from1, $until1);
// Get initial prices
$initialPrices = $this->cart->items->pluck('price')->sort()->values()->toArray();
// Change to different dates (same duration)
$from2 = now()->addDays(5);
$until2 = now()->addDays(6);
$this->cart->setDates($from2, $until2);
$this->cart->refresh();
$this->cart->load('items');
// Prices should be the same (only dates changed, not duration)
$newPrices = $this->cart->items->pluck('price')->sort()->values()->toArray();
$this->assertEquals(
$initialPrices,
$newPrices,
'Prices should remain consistent when only dates change (same duration)'
);
}
#[Test]
public function cart_item_is_not_ready_for_checkout_if_already_booked_on_same_dates()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(4);
$this->assertEquals(3, $pool->getPoolMaxQuantity($from, $until), 'No singles should be available after booking');
$cart = $this->user->currentCart();
$cart->addToCart($pool, 2, [], $from, $until);
foreach ($cart->items as $item) {
$this->assertTrue($item->is_ready_to_checkout, 'Item should be ready before booking');
}
$this->assertTrue($cart->is_ready_to_checkout, 'Cart should be ready before booking');
$cart->checkout();
$this->assertEquals(1, $pool->getPoolMaxQuantity($from, $until), 'No singles should be available after booking');
$cart = $this->user->currentCart();
$cart->addToCart($pool, 3);
foreach ($cart->items as $item) {
$this->assertFalse($item->is_ready_to_checkout, 'Item should not be ready after singles are booked');
}
$this->assertFalse($cart->is_ready_to_checkout, 'Cart should not be ready after singles are booked');
$cart->setDates($from, $until);
// After setting dates where only 1 single is available but we have 3 items,
// only 1 item should be ready (the first one up to the available capacity)
$readies = 0;
foreach ($cart->items as $item) {
if ($item->is_ready_to_checkout) {
$readies++;
}
}
$this->assertEquals(1, $readies, '1 item should be ready (1 single available)');
$this->assertFalse($cart->is_ready_to_checkout);
$offset = 4;
$cart->setDates(
$from->copy()->addDays($offset),
$until->copy()->addDays($offset)
);
$readies = 0;
foreach ($cart->items as $item) {
if ($item->is_ready_to_checkout) {
$readies++;
}
}
$this->assertEquals(3, $readies, '3 items should be ready');
$this->assertTrue($cart->is_ready_to_checkout);
$offset = 3;
$cart->setDates(
$from->copy()->addDays($offset),
$until->copy()->addDays($offset)
);
$readies = 0;
foreach ($cart->items as $item) {
if ($item->is_ready_to_checkout) {
$readies++;
}
}
// With offset 3, the new period starts exactly when the booked period ends.
// In hotel-style bookings, checkout day = checkin day does NOT overlap,
// so all 3 singles should be available.
$this->assertEquals(3, $readies, '3 items should be ready (no overlap with offset 3)');
$this->assertTrue($cart->is_ready_to_checkout);
$offset = 2;
$cart->setDates(
$from->copy()->addDays($offset),
$until->copy()->addDays($offset)
);
$readies = 0;
foreach ($cart->items as $item) {
if ($item->is_ready_to_checkout) {
$readies++;
}
}
$this->assertEquals(1, $readies, '1 item should be ready (no overlap with offset 2)');
$this->assertFalse($cart->is_ready_to_checkout);
}
}

View File

@ -126,13 +126,13 @@ class CartItemAvailabilityValidationTest extends TestCase
$this->cart->addToCart($pool, 3, [], $from, $until); $this->cart->addToCart($pool, 3, [], $from, $until);
// Manually simulate an item becoming unavailable: // Manually simulate an item becoming unavailable:
// - Remove allocation // - Remove allocation (product_id = null)
// - Set price to null (the real indicator of unavailability) // - Set price to null (the real indicator of unavailability)
$item = $this->cart->items()->first(); $item = $this->cart->items()->first();
$meta = $item->getMeta(); $meta = $item->getMeta();
unset($meta->allocated_single_item_id);
unset($meta->allocated_single_item_name); unset($meta->allocated_single_item_name);
$item->update([ $item->update([
'product_id' => null,
'meta' => json_encode($meta), 'meta' => json_encode($meta),
'price' => null, 'price' => null,
'subtotal' => null, 'subtotal' => null,
@ -245,8 +245,7 @@ class CartItemAvailabilityValidationTest extends TestCase
// Verify all items are allocated and ready // Verify all items are allocated and ready
foreach ($this->cart->items as $item) { foreach ($this->cart->items as $item) {
$meta = $item->getMeta(); $this->assertNotNull($item->product_id, 'Item should have product_id allocated');
$this->assertNotNull($meta->allocated_single_item_id ?? null, 'Item should be allocated');
$this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready'); $this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready');
} }

View File

@ -0,0 +1,232 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PricingStrategy;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Workbench\App\Models\User;
use PHPUnit\Framework\Attributes\Test;
class PoolProductPriceIdTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected Product $poolProduct;
protected Product $singleItem1;
protected Product $singleItem2;
protected ProductPrice $price1;
protected ProductPrice $price2;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
// Create pool product
$this->poolProduct = Product::factory()->create([
'name' => 'Parking Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// Create single items with different prices
$this->singleItem1 = Product::factory()->create([
'name' => 'Parking Spot 1',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->singleItem1->increaseStock(1);
$this->singleItem2 = Product::factory()->create([
'name' => 'Parking Spot 2',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->singleItem2->increaseStock(1);
// Set prices on single items
$this->price1 = ProductPrice::factory()->create([
'purchasable_id' => $this->singleItem1->id,
'purchasable_type' => Product::class,
'unit_amount' => 2000, // $20/day
'currency' => 'USD',
'is_default' => true,
]);
$this->price2 = ProductPrice::factory()->create([
'purchasable_id' => $this->singleItem2->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000, // $50/day
'currency' => 'USD',
'is_default' => true,
]);
// Link single items to pool
$this->poolProduct->productRelations()->attach($this->singleItem1->id, [
'type' => ProductRelationType::SINGLE->value,
]);
$this->poolProduct->productRelations()->attach($this->singleItem2->id, [
'type' => ProductRelationType::SINGLE->value,
]);
}
#[Test]
public function it_stores_single_item_price_id_when_adding_pool_to_cart_with_lowest_strategy()
{
// Set pricing strategy to lowest (default)
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
// Add pool to cart - should use the lowest price (singleItem1's price)
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Assert the cart item has the price_id from the single item, not the pool
$this->assertNotNull($cartItem->price_id);
$this->assertEquals($this->price1->id, $cartItem->price_id);
$this->assertEquals(2000, $cartItem->price); // $20
}
#[Test]
public function it_stores_correct_price_id_for_second_pool_item_with_progressive_pricing()
{
// Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
// Add first pool item - should use lowest price (singleItem1)
$cartItem1 = $this->cart->addToCart($this->poolProduct, 1);
$this->assertEquals($this->price1->id, $cartItem1->price_id);
$this->assertEquals(2000, $cartItem1->price);
// Add second pool item - should use next lowest price (singleItem2)
$cartItem2 = $this->cart->addToCart($this->poolProduct, 1);
$this->assertEquals($this->price2->id, $cartItem2->price_id);
$this->assertEquals(5000, $cartItem2->price);
}
#[Test]
public function it_stores_single_item_price_id_with_highest_strategy()
{
// Set pricing strategy to highest
$this->poolProduct->setPoolPricingStrategy('highest');
// Add pool to cart - should use the highest price (singleItem2's price)
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Assert the cart item has the price_id from the single item with highest price
$this->assertNotNull($cartItem->price_id);
$this->assertEquals($this->price2->id, $cartItem->price_id);
$this->assertEquals(5000, $cartItem->price); // $50
}
#[Test]
public function it_stores_allocated_single_item_in_product_id_column()
{
// Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
// Add pool to cart
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Check product_id column contains allocated single item id
$this->assertNotNull($cartItem->product_id);
$this->assertEquals($this->singleItem1->id, $cartItem->product_id);
// Meta should still have the name for display purposes
$meta = $cartItem->getMeta();
$this->assertEquals($this->singleItem1->name, $meta->allocated_single_item_name);
}
#[Test]
public function it_stores_different_single_items_in_product_id_for_progressive_pricing()
{
// Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
// Add first pool item
$cartItem1 = $this->cart->addToCart($this->poolProduct, 1);
$this->assertEquals($this->singleItem1->id, $cartItem1->product_id);
// Add second pool item
$cartItem2 = $this->cart->addToCart($this->poolProduct, 1);
$this->assertEquals($this->singleItem2->id, $cartItem2->product_id);
}
#[Test]
public function it_uses_pool_price_id_when_pool_has_direct_price_and_no_single_item_prices()
{
// Remove prices from single items
$this->price1->delete();
$this->price2->delete();
// Set a direct price on the pool itself
$poolPrice = ProductPrice::factory()->create([
'purchasable_id' => $this->poolProduct->id,
'purchasable_type' => Product::class,
'unit_amount' => 3000, // $30
'currency' => 'USD',
'is_default' => true,
]);
// Add pool to cart - should use pool's direct price as fallback
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Assert the cart item has the pool's price_id
$this->assertEquals($poolPrice->id, $cartItem->price_id);
$this->assertEquals(3000, $cartItem->price);
// product_id should indicate which single item was allocated
// Even though the pool's price is used as fallback, one of the single items is still allocated
$this->assertNotNull($cartItem->product_id);
$this->assertTrue(
$cartItem->product_id === $this->singleItem1->id ||
$cartItem->product_id === $this->singleItem2->id,
'Allocated single item should be one of the pool\'s single items'
);
}
#[Test]
public function it_stores_price_id_with_average_pricing_strategy()
{
// Set pricing strategy to average
$this->poolProduct->setPricingStrategy(PricingStrategy::AVERAGE);
// Add pool to cart - should use average price but store first item's price_id
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Average of 2000 and 5000 = 3500
$this->assertEquals(3500, $cartItem->price);
// Should store a price_id (from one of the single items)
$this->assertNotNull($cartItem->price_id);
$this->assertTrue(
$cartItem->price_id === $this->price1->id || $cartItem->price_id === $this->price2->id,
'Price ID should be from one of the single items'
);
}
#[Test]
public function it_stores_correct_price_id_with_booking_dates()
{
// Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
$from = now()->addDays(1)->startOfDay();
$until = now()->addDays(3)->startOfDay(); // 2 days
// Add pool to cart with dates
$cartItem = $this->cart->addToCart($this->poolProduct, 1, [], $from, $until);
// Should use lowest price and store its price_id
$this->assertEquals($this->price1->id, $cartItem->price_id);
$this->assertEquals(4000, $cartItem->price); // $20 × 2 days
}
}

File diff suppressed because it is too large Load Diff

View File

@ -128,7 +128,7 @@ class PoolProductPriceIdTest extends TestCase
} }
#[Test] #[Test]
public function it_stores_allocated_single_item_in_meta() public function it_stores_allocated_single_item_in_product_id_column()
{ {
// Set pricing strategy to lowest // Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
@ -136,28 +136,28 @@ class PoolProductPriceIdTest extends TestCase
// Add pool to cart // Add pool to cart
$cartItem = $this->cart->addToCart($this->poolProduct, 1); $cartItem = $this->cart->addToCart($this->poolProduct, 1);
// Check meta contains allocated single item info // Check product_id column contains allocated single item id
$this->assertNotNull($cartItem->product_id);
$this->assertEquals($this->singleItem1->id, $cartItem->product_id);
// Meta should still have the name for display purposes
$meta = $cartItem->getMeta(); $meta = $cartItem->getMeta();
$this->assertNotNull($meta->allocated_single_item_id ?? null);
$this->assertEquals($this->singleItem1->id, $meta->allocated_single_item_id);
$this->assertEquals($this->singleItem1->name, $meta->allocated_single_item_name); $this->assertEquals($this->singleItem1->name, $meta->allocated_single_item_name);
} }
#[Test] #[Test]
public function it_stores_different_single_items_in_meta_for_progressive_pricing() public function it_stores_different_single_items_in_product_id_for_progressive_pricing()
{ {
// Set pricing strategy to lowest // Set pricing strategy to lowest
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
// Add first pool item // Add first pool item
$cartItem1 = $this->cart->addToCart($this->poolProduct, 1); $cartItem1 = $this->cart->addToCart($this->poolProduct, 1);
$meta1 = $cartItem1->getMeta(); $this->assertEquals($this->singleItem1->id, $cartItem1->product_id);
$this->assertEquals($this->singleItem1->id, $meta1->allocated_single_item_id);
// Add second pool item // Add second pool item
$cartItem2 = $this->cart->addToCart($this->poolProduct, 1); $cartItem2 = $this->cart->addToCart($this->poolProduct, 1);
$meta2 = $cartItem2->getMeta(); $this->assertEquals($this->singleItem2->id, $cartItem2->product_id);
$this->assertEquals($this->singleItem2->id, $meta2->allocated_single_item_id);
} }
#[Test] #[Test]
@ -183,13 +183,12 @@ class PoolProductPriceIdTest extends TestCase
$this->assertEquals($poolPrice->id, $cartItem->price_id); $this->assertEquals($poolPrice->id, $cartItem->price_id);
$this->assertEquals(3000, $cartItem->price); $this->assertEquals(3000, $cartItem->price);
// Meta should indicate which single item was allocated // product_id should indicate which single item was allocated
// Even though the pool's price is used as fallback, one of the single items is still allocated // Even though the pool's price is used as fallback, one of the single items is still allocated
$meta = $cartItem->getMeta(); $this->assertNotNull($cartItem->product_id);
$this->assertNotNull($meta->allocated_single_item_id ?? null);
$this->assertTrue( $this->assertTrue(
$meta->allocated_single_item_id === $this->singleItem1->id || $cartItem->product_id === $this->singleItem1->id ||
$meta->allocated_single_item_id === $this->singleItem2->id, $cartItem->product_id === $this->singleItem2->id,
'Allocated single item should be one of the pool\'s single items' 'Allocated single item should be one of the pool\'s single items'
); );
} }

View File

@ -838,7 +838,7 @@ class PoolProductionBugTest extends TestCase
'id' => $item->id, 'id' => $item->id,
'quantity' => $item->quantity, 'quantity' => $item->quantity,
'price' => $item->price, 'price' => $item->price,
'allocated_id' => $meta->allocated_single_item_id ?? null, 'allocated_id' => $item->product_id,
'allocated_name' => $meta->allocated_single_item_name ?? 'none', 'allocated_name' => $meta->allocated_single_item_name ?? 'none',
]; ];
} }
@ -855,7 +855,7 @@ class PoolProductionBugTest extends TestCase
'id' => $item->id, 'id' => $item->id,
'quantity' => $item->quantity, 'quantity' => $item->quantity,
'price' => $item->price, 'price' => $item->price,
'allocated_id' => $meta->allocated_single_item_id ?? null, 'allocated_id' => $item->product_id,
'allocated_name' => $meta->allocated_single_item_name ?? 'none', 'allocated_name' => $meta->allocated_single_item_name ?? 'none',
]; ];
$totalQuantity += $item->quantity; $totalQuantity += $item->quantity;
@ -870,7 +870,7 @@ class PoolProductionBugTest extends TestCase
// The issue: when items are merged, the allocation tracking might not work correctly // The issue: when items are merged, the allocation tracking might not work correctly
// Each distinct single item should NOT be merged with others // Each distinct single item should NOT be merged with others
// Items from the SAME single CAN be merged (they have same price and same allocated_single_item_id) // Items from the SAME single CAN be merged (they have same price and same product_id)
// Check that we have correct allocations: // Check that we have correct allocations:
// - 3 quantity allocated to single_1 // - 3 quantity allocated to single_1
@ -879,14 +879,13 @@ class PoolProductionBugTest extends TestCase
$single2Quantity = 0; $single2Quantity = 0;
$single3Quantity = 0; $single3Quantity = 0;
// Verify EACH cart item has allocated_single_item_id set // Verify EACH cart item has product_id set
foreach ($cartItems as $item) { foreach ($cartItems as $item) {
$meta = $item->getMeta(); $allocatedId = $item->product_id;
$allocatedId = $meta->allocated_single_item_id ?? null;
$this->assertNotNull( $this->assertNotNull(
$allocatedId, $allocatedId,
'Cart item id=' . $item->id . ' (qty=' . $item->quantity . ', price=' . $item->price . 'Cart item id=' . $item->id . ' (qty=' . $item->quantity . ', price=' . $item->price .
') should have allocated_single_item_id but has: ' . json_encode($meta) ') should have product_id but has: null'
); );
if ($allocatedId == $single_1->id) { if ($allocatedId == $single_1->id) {
@ -958,8 +957,7 @@ class PoolProductionBugTest extends TestCase
->get(); ->get();
$usageMap = []; $usageMap = [];
foreach ($cartItemsAsSeenByPool as $ci) { foreach ($cartItemsAsSeenByPool as $ci) {
$meta = $ci->getMeta(); $allocatedId = $ci->product_id;
$allocatedId = $meta->allocated_single_item_id ?? null;
if ($allocatedId) { if ($allocatedId) {
$usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity; $usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity;
} }
@ -1002,14 +1000,13 @@ class PoolProductionBugTest extends TestCase
); );
$item5 = $freshCart->addToCart($freshPool, 1); $item5 = $freshCart->addToCart($freshPool, 1);
$meta5 = $item5->getMeta();
// Verify item 5 is allocated to single_3 (the only one with remaining capacity) // Verify item 5 is allocated to single_3 (the only one with remaining capacity)
$this->assertEquals( $this->assertEquals(
$single_3->id, $single_3->id,
$meta5->allocated_single_item_id, $item5->product_id,
'Item 5 should be allocated to single_3 (id=' . $single_3->id . ') since single_1 and single_2 are exhausted. ' . 'Item 5 should be allocated to single_3 (id=' . $single_3->id . ') since single_1 and single_2 are exhausted. ' .
'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null') 'Got product_id: ' . ($item5->product_id ?? 'null')
); );
// Check if item 5 is actually a new item or a merged item // Check if item 5 is actually a new item or a merged item
@ -1026,9 +1023,9 @@ class PoolProductionBugTest extends TestCase
$this->assertEquals( $this->assertEquals(
$single_3->id, $single_3->id,
$meta5->allocated_single_item_id, $item5->product_id,
'Item 5 should be from single_3 (id=' . $single_3->id . ', name=' . $single_3->name . '). ' . 'Item 5 should be from single_3 (id=' . $single_3->id . ', name=' . $single_3->name . '). ' .
'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null') . '. ' . 'Got product_id: ' . ($item5->product_id ?? 'null') . '. ' .
'For reference: single_1=' . $single_1->id . ', single_2=' . $single_2->id 'For reference: single_1=' . $single_1->id . ', single_2=' . $single_2->id
); );
$this->assertEquals(20004, $item5->price, 'Item 5 should cost 20004 (10002 * 2 days)'); $this->assertEquals(20004, $item5->price, 'Item 5 should cost 20004 (10002 * 2 days)');
@ -1081,7 +1078,7 @@ class PoolProductionBugTest extends TestCase
// Add first item - should get single_1 // Add first item - should get single_1
$item1 = $cart->addToCart($pool, 1); $item1 = $cart->addToCart($pool, 1);
$this->assertEquals($single_1->id, $item1->getMeta()->allocated_single_item_id); $this->assertEquals($single_1->id, $item1->product_id);
// After 1 item, next should still be single_1 (has 2 stock) // After 1 item, next should still be single_1 (has 2 stock)
$cart->refresh(); $cart->refresh();
@ -1090,7 +1087,7 @@ class PoolProductionBugTest extends TestCase
// Add second item - should get single_1 again // Add second item - should get single_1 again
$item2 = $cart->addToCart($pool, 1); $item2 = $cart->addToCart($pool, 1);
$this->assertEquals($single_1->id, $item2->getMeta()->allocated_single_item_id); $this->assertEquals($single_1->id, $item2->product_id);
// After 2 items (both from single_1 which has stock=2), next should be single_2 // After 2 items (both from single_1 which has stock=2), next should be single_2
$cart->refresh(); $cart->refresh();
@ -1100,7 +1097,7 @@ class PoolProductionBugTest extends TestCase
// Add third item - should get single_2 // Add third item - should get single_2
$item3 = $cart->addToCart($pool, 1); $item3 = $cart->addToCart($pool, 1);
$this->assertEquals($single_2->id, $item3->getMeta()->allocated_single_item_id); $this->assertEquals($single_2->id, $item3->product_id);
// Total: 1000 + 1000 + 2000 = 4000 // Total: 1000 + 1000 + 2000 = 4000
$this->assertEquals(4000, $cart->fresh()->getTotal()); $this->assertEquals(4000, $cart->fresh()->getTotal());

View File

@ -0,0 +1,748 @@
<?php
namespace Blax\Shop\Tests\Feature\ProductionBugs;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Production Bug: Pool pricing jumps to 5000 when adjusting dates
*
* Scenario:
* - Pool: Parkings (price: 2800)
* - Singles: Vip 1, Vip 2, Vip 3 (NO price - should use pool's 2800)
* - Singles: Executive 1, Executive 2 (price: 5000 each)
* - All singles have 1 stock
*
* Bug: getCurrentPrice on pool shows 2800 (correct), but when dates are adjusted,
* the cart item price jumps to 5000.
*
* Expected: With LOWEST pricing strategy, items should be allocated to Vip items first
* (using pool fallback price of 2800), not Executive items (5000).
*/
class PoolPricingReallocationBugTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Product $pool;
protected array $vipItems = [];
protected array $executiveItems = [];
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
auth()->login($this->user);
$this->createParkingPool();
}
/**
* Create the parking pool with Vip (no price) and Executive (5000) items
*/
protected function createParkingPool(): void
{
// Create pool product with price 2800
$this->pool = Product::factory()->create([
'name' => 'Parkings',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// Set pricing strategy to lowest
$this->pool->setPoolPricingStrategy('lowest');
// Pool has price of 2800
ProductPrice::factory()->create([
'purchasable_id' => $this->pool->id,
'purchasable_type' => Product::class,
'unit_amount' => 2800,
'currency' => 'USD',
'is_default' => true,
]);
// Create 3 Vip items WITHOUT prices (should fallback to pool price 2800)
for ($i = 1; $i <= 3; $i++) {
$vip = Product::factory()->create([
'name' => "Vip $i",
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$vip->increaseStock(1);
// NO price - should use pool fallback
$this->vipItems[] = $vip;
}
// Create 2 Executive items WITH prices of 5000
for ($i = 1; $i <= 2; $i++) {
$exec = Product::factory()->create([
'name' => "Executive $i",
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$exec->increaseStock(1);
ProductPrice::factory()->create([
'purchasable_id' => $exec->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$this->executiveItems[] = $exec;
}
// Attach all singles to pool (Vip items first, then Executive)
$allSingles = array_merge(
array_map(fn($p) => $p->id, $this->vipItems),
array_map(fn($p) => $p->id, $this->executiveItems)
);
$this->pool->attachSingleItems($allSingles);
}
// =========================================================================
// Basic price verification tests
// =========================================================================
#[Test]
public function pool_get_current_price_returns_2800()
{
// getCurrentPrice should return 2800 (the pool's price)
$price = $this->pool->getCurrentPrice();
$this->assertEquals(2800, $price, 'Pool getCurrentPrice should return 2800');
}
#[Test]
public function vip_items_have_no_direct_price()
{
foreach ($this->vipItems as $vip) {
$price = $vip->defaultPrice()->first();
$this->assertNull($price, "Vip item {$vip->name} should have no price");
}
}
#[Test]
public function executive_items_have_price_5000()
{
foreach ($this->executiveItems as $exec) {
$priceModel = $exec->defaultPrice()->first();
$this->assertNotNull($priceModel, "Executive item {$exec->name} should have a price");
$this->assertEquals(
5000,
$priceModel->getCurrentPrice(),
"Executive item {$exec->name} should have price 5000"
);
}
}
// =========================================================================
// Cart pricing tests - adding to cart
// =========================================================================
#[Test]
public function add_first_item_to_cart_should_use_lowest_price_2800()
{
$from = Carbon::now()->addDay();
$until = Carbon::now()->addDays(2);
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$item = $cart->items->first();
// Should be allocated to a Vip item (using pool fallback price 2800)
$this->assertNotNull($item, 'Cart should have an item');
$this->assertEquals(
2800,
$item->price,
'First item should use lowest price (2800 from pool fallback)'
);
// Verify allocated to a Vip item (now stored in product_id column)
$allocatedSingleId = $item->product_id;
$vipIds = array_map(fn($p) => $p->id, $this->vipItems);
$this->assertContains(
$allocatedSingleId,
$vipIds,
'Item should be allocated to a Vip single (lowest price)'
);
}
#[Test]
public function add_multiple_items_should_fill_vip_before_executive()
{
$from = Carbon::now()->addDay();
$until = Carbon::now()->addDays(2);
// Add 3 items - should fill all 3 Vip spots at 2800 each
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 3, [], $from, $until);
$cart->refresh();
$totalPrice = $cart->items->sum('price');
// 3 x 2800 = 8400
$this->assertEquals(
8400,
$totalPrice,
'Three items should cost 8400 (3 x 2800 from Vip items)'
);
}
#[Test]
public function adding_4th_item_should_use_executive_at_5000()
{
$from = Carbon::now()->addDay();
$until = Carbon::now()->addDays(2);
// Add 4 items - 3 Vip at 2800 + 1 Executive at 5000
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 4, [], $from, $until);
$cart->refresh();
$totalPrice = $cart->items->sum('price');
// 3 x 2800 + 1 x 5000 = 8400 + 5000 = 13400
$this->assertEquals(
13400,
$totalPrice,
'Four items should cost 13400 (3 x 2800 + 1 x 5000)'
);
}
// =========================================================================
// BUG REPRODUCTION: Date adjustment causes price jump
// =========================================================================
#[Test]
public function adjusting_dates_should_maintain_2800_price_for_vip_allocation()
{
$from = Carbon::now()->addDay();
$until = Carbon::now()->addDays(2);
// Add 1 item - should be allocated to Vip at 2800
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$item = $cart->items->first();
$this->assertEquals(2800, $item->price, 'Initial price should be 2800');
// Now adjust dates
$newFrom = Carbon::now()->addDays(3);
$newUntil = Carbon::now()->addDays(4);
$cart->setFromDate($newFrom);
$cart->setUntilDate($newUntil);
$cart->refresh();
$item = $cart->items->first();
// BUG: Price should still be 2800, NOT 5000
$this->assertEquals(
2800,
$item->price,
'After adjusting dates, price should still be 2800 (not jump to 5000)'
);
}
#[Test]
public function adjusting_until_date_should_maintain_lowest_price()
{
$from = Carbon::now()->addDay()->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// First set cart dates, then add item
$cart = $this->user->currentCart();
$cart->setFromDate($from);
$cart->setUntilDate($until);
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$initialPrice = $cart->items->first()->price;
$this->assertEquals(2800, $initialPrice, 'Initial price for 1 day should be 2800');
// Now extend the until date to add more days
$newUntil = Carbon::now()->addDays(4)->startOfDay();
$cart->setUntilDate($newUntil);
$cart->refresh();
$item = $cart->items->first();
$days = 3; // from addDay() (day 1) to addDays(4) (day 4) = 3 days
$expectedPrice = 2800 * $days;
// Price should scale with days but base should still be 2800
$this->assertEquals(
$expectedPrice,
$item->price,
"After extending until date, price should be {$expectedPrice} (2800 x {$days} days)"
);
}
#[Test]
public function updating_cart_item_dates_directly_should_maintain_lowest_price()
{
$from = Carbon::now()->addDay();
$until = Carbon::now()->addDays(2);
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$item = $cart->items->first();
$this->assertEquals(2800, $item->price, 'Initial price should be 2800');
// Update dates directly on the cart item
$newFrom = Carbon::now()->addDays(5);
$newUntil = Carbon::now()->addDays(6);
$item->updateDates($newFrom, $newUntil);
$item->refresh();
// BUG: Price should still be 2800, NOT 5000
$this->assertEquals(
2800,
$item->price,
'After updating item dates directly, price should still be 2800'
);
}
#[Test]
public function price_should_stay_2800_when_reallocating_with_dates_where_vip_is_available()
{
$from = Carbon::now()->addDay();
$until = Carbon::now()->addDays(2);
// Add 1 item
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$item = $cart->items->first();
$originalAllocation = $item->product_id;
// Record original price
$originalPrice = $item->price;
$this->assertEquals(2800, $originalPrice);
// Change to different dates where same Vip should still be available
$newFrom = Carbon::now()->addDays(10);
$newUntil = Carbon::now()->addDays(11);
$cart->setFromDate($newFrom);
$cart->setUntilDate($newUntil);
$cart->refresh();
$item = $cart->items->first();
// Price should remain 2800 (still allocated to a Vip)
$this->assertEquals(
2800,
$item->price,
'Price should remain 2800 after date change when Vip items are available'
);
// Should still be allocated to a Vip item
$allocatedSingleId = $item->product_id;
$vipIds = array_map(fn($p) => $p->id, $this->vipItems);
$this->assertContains(
$allocatedSingleId,
$vipIds,
'Should remain allocated to a Vip item after date change'
);
}
// =========================================================================
// Edge cases
// =========================================================================
#[Test]
public function when_all_vip_claimed_new_item_gets_executive_at_5000()
{
$from = Carbon::now()->addDay()->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// Fill all 3 Vip spots by claiming stock
foreach ($this->vipItems as $vip) {
$vip->claimStock(1, null, $from, $until, 'Test claim');
}
// Now add 1 item - should get Executive at 5000 (since all Vips are claimed)
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$item = $cart->items->first();
$this->assertEquals(
5000,
$item->price,
'Item should be allocated to Executive at 5000 when all Vips are claimed'
);
// Verify allocated to an Executive item
$allocatedSingleId = $item->product_id;
$execIds = array_map(fn($p) => $p->id, $this->executiveItems);
$this->assertContains(
$allocatedSingleId,
$execIds,
'Item should be allocated to an Executive single'
);
}
#[Test]
public function reallocation_after_date_change_respects_pricing_strategy()
{
// Use different dates to avoid conflicts
$from1 = Carbon::now()->addDays(1);
$until1 = Carbon::now()->addDays(2);
$from2 = Carbon::now()->addDays(10);
$until2 = Carbon::now()->addDays(11);
// Add 2 items at dates1
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 2, [], $from1, $until1);
$cart->refresh();
$prices = $cart->items->pluck('price')->toArray();
$this->assertEquals(
[2800, 2800],
$prices,
'Both items should be 2800 (allocated to Vip items)'
);
// Change dates to dates2 where all singles should be available
$cart->setFromDate($from2);
$cart->setUntilDate($until2);
$cart->refresh();
$prices = $cart->items->pluck('price')->toArray();
// Should still be allocated to lowest-priced items (Vip at 2800)
$this->assertEquals(
[2800, 2800],
$prices,
'After date change, both items should still be 2800'
);
}
#[Test]
public function multiple_date_adjustments_maintain_correct_pricing()
{
$from = Carbon::now()->addDay();
$until = Carbon::now()->addDays(2);
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$this->assertEquals(2800, $cart->items->first()->price);
// Adjust dates multiple times
for ($i = 0; $i < 5; $i++) {
$newFrom = Carbon::now()->addDays(10 + $i * 5);
$newUntil = Carbon::now()->addDays(11 + $i * 5);
$cart->setFromDate($newFrom);
$cart->setUntilDate($newUntil);
$cart->refresh();
$this->assertEquals(
2800,
$cart->items->first()->price,
"After adjustment #{$i}, price should still be 2800"
);
}
}
// =========================================================================
// Additional edge case tests for production bug investigation
// =========================================================================
#[Test]
public function adding_item_without_dates_then_setting_dates_uses_lowest_price()
{
// Add item WITHOUT dates first
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1);
$cart->refresh();
$item = $cart->items->first();
// Item should exist but may not have a full price yet (no dates)
$this->assertNotNull($item);
// Now set dates on the cart
$from = Carbon::now()->addDay()->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
$cart->setFromDate($from);
$cart->setUntilDate($until);
$cart->refresh();
$item = $cart->items->first();
// Should be allocated to Vip (lowest price 2800)
$this->assertEquals(
2800,
$item->price,
'After setting dates, price should be 2800 (lowest via Vip)'
);
// Verify allocated to a Vip item
$allocatedSingleId = $item->product_id;
$vipIds = array_map(fn($p) => $p->id, $this->vipItems);
$this->assertContains(
$allocatedSingleId,
$vipIds,
'Item should be allocated to a Vip single (lowest price)'
);
}
#[Test]
public function setting_dates_on_cart_with_pool_item_allocates_to_lowest()
{
$cart = $this->user->currentCart();
// Set cart dates first
$from = Carbon::now()->addDay()->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
$cart->update(['from' => $from, 'until' => $until]);
// Then add item without explicitly passing dates
$cart->addToCart($this->pool, 1);
$cart->refresh();
$item = $cart->items->first();
// Should be allocated to Vip (lowest price 2800)
$this->assertEquals(
2800,
$item->price,
'Item should use lowest price 2800 from Vip'
);
}
#[Test]
public function debugging_reallocation_order()
{
$from = Carbon::now()->addDay()->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$item = $cart->items->first();
$initialAllocation = $item->product_id;
$initialPrice = $item->price;
// Verify initial state
$this->assertEquals(2800, $initialPrice, 'Initial price should be 2800');
$vipIds = array_map(fn($p) => $p->id, $this->vipItems);
$this->assertContains($initialAllocation, $vipIds, 'Should be allocated to Vip');
// Change dates multiple times and track what happens
for ($i = 1; $i <= 3; $i++) {
$newFrom = Carbon::now()->addDays($i * 10)->startOfDay();
$newUntil = Carbon::now()->addDays($i * 10 + 1)->startOfDay();
$cart->setFromDate($newFrom);
$cart->setUntilDate($newUntil);
$cart->refresh();
$item = $cart->items->first();
$newAllocation = $item->product_id;
$newPrice = $item->price;
// Verify still allocated to Vip and still 2800
$this->assertContains(
$newAllocation,
$vipIds,
"After iteration {$i}, should still be allocated to Vip"
);
$this->assertEquals(
2800,
$newPrice,
"After iteration {$i}, price should still be 2800"
);
}
}
#[Test]
public function cross_sell_pool_pricing_uses_lowest()
{
// Create a hotel room product
$hotelRoom = Product::factory()->create([
'name' => 'Hotel Room',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$hotelRoom->increaseStock(5);
ProductPrice::factory()->create([
'purchasable_id' => $hotelRoom->id,
'purchasable_type' => Product::class,
'unit_amount' => 10000,
'currency' => 'USD',
'is_default' => true,
]);
// Attach pool as cross-sell to hotel room
$hotelRoom->productRelations()->attach($this->pool->id, [
'type' => \Blax\Shop\Enums\ProductRelationType::CROSS_SELL->value,
]);
$from = Carbon::now()->addDay()->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// Add hotel room first
$cart = $this->user->currentCart();
$cart->addToCart($hotelRoom, 1, [], $from, $until);
// Add the cross-sell pool (parking)
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
// Find the parking item
$parkingItem = $cart->items->first(fn($item) => $item->purchasable_id === $this->pool->id);
$this->assertNotNull($parkingItem, 'Parking item should exist');
$this->assertEquals(
2800,
$parkingItem->price,
'Parking cross-sell should use lowest price 2800'
);
// Adjust dates
$newFrom = Carbon::now()->addDays(5)->startOfDay();
$newUntil = Carbon::now()->addDays(6)->startOfDay();
$cart->setFromDate($newFrom);
$cart->setUntilDate($newUntil);
$cart->refresh();
$parkingItem = $cart->items->first(fn($item) => $item->purchasable_id === $this->pool->id);
$this->assertEquals(
2800,
$parkingItem->price,
'After date adjustment, parking should still be 2800'
);
}
#[Test]
public function when_allocated_vip_becomes_unavailable_reallocates_to_next_cheapest()
{
$from = Carbon::now()->addDay()->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// Add 1 item - should get allocated to Vip 1 at 2800
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$item = $cart->items->first();
$allocatedVipId = $item->product_id;
$this->assertEquals(2800, $item->price);
// Now claim that specific Vip for different dates (simulating another booking)
$newFrom = Carbon::now()->addDays(5)->startOfDay();
$newUntil = Carbon::now()->addDays(6)->startOfDay();
// Claim the allocated Vip for the new date range
$allocatedVip = Product::find($allocatedVipId);
$allocatedVip->claimStock(1, null, $newFrom, $newUntil, 'Other booking');
// Now change cart dates to the new range where that Vip is claimed
$cart->setFromDate($newFrom);
$cart->setUntilDate($newUntil);
$cart->refresh();
$item = $cart->items->first();
// Should be reallocated to another Vip (there are 3 Vips)
// Price should still be 2800 (another Vip is available)
$this->assertEquals(
2800,
$item->price,
'When original Vip is claimed, should reallocate to another Vip at 2800'
);
$newAllocatedId = $item->product_id;
// Should be a different Vip
$this->assertNotEquals(
$allocatedVipId,
$newAllocatedId,
'Should be reallocated to a different single item'
);
$vipIds = array_map(fn($p) => $p->id, $this->vipItems);
$this->assertContains(
$newAllocatedId,
$vipIds,
'Should still be allocated to a Vip item'
);
}
#[Test]
public function when_all_vips_unavailable_reallocates_to_executive()
{
$from = Carbon::now()->addDay()->startOfDay();
$until = Carbon::now()->addDays(2)->startOfDay();
// Add 1 item - should get allocated to Vip 1 at 2800
$cart = $this->user->currentCart();
$cart->addToCart($this->pool, 1, [], $from, $until);
$cart->refresh();
$item = $cart->items->first();
$this->assertEquals(2800, $item->price);
// Now claim ALL Vips for different dates
$newFrom = Carbon::now()->addDays(5)->startOfDay();
$newUntil = Carbon::now()->addDays(6)->startOfDay();
foreach ($this->vipItems as $vip) {
$vip->claimStock(1, null, $newFrom, $newUntil, 'Other booking');
}
// Change cart dates to the new range where all Vips are claimed
$cart->setFromDate($newFrom);
$cart->setUntilDate($newUntil);
$cart->refresh();
$item = $cart->items->first();
// Should be reallocated to Executive at 5000 (only option left)
$this->assertEquals(
5000,
$item->price,
'When all Vips are claimed, should reallocate to Executive at 5000'
);
$allocatedId = $item->product_id;
$execIds = array_map(fn($p) => $p->id, $this->executiveItems);
$this->assertContains(
$allocatedId,
$execIds,
'Should be allocated to an Executive item'
);
}
}

View File

@ -550,4 +550,207 @@ class StripeWebhookOrderTest extends TestCase
$this->assertNotNull($failNote); $this->assertNotNull($failNote);
$this->assertStringContainsString('declined', $failNote->content); $this->assertStringContainsString('declined', $failNote->content);
} }
// =========================================================================
// STRIPE CHECKOUT SESSION FLOW TESTS (No pre-existing order)
// =========================================================================
#[Test]
public function checkout_session_completed_creates_order_when_none_exists()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
// Add to cart but DON'T call checkoutCart() - simulate checkoutSession() flow
$customer->addToCart($product);
$cart = $customer->currentCart();
// Verify no order exists yet
$this->assertNull($cart->order);
// Simulate what checkoutSession() does: mark cart as converted
$cart->update([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
// Now simulate checkout session completed webhook
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 10000, // 100.00
'payment_status' => 'paid',
]);
$result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$this->assertTrue($result);
// Verify order was created
$cart->refresh();
$order = $cart->order;
$this->assertNotNull($order, 'Order should be created by webhook');
$this->assertEquals($cart->id, $order->cart_id);
$this->assertEquals($customer->id, $order->customer_id);
}
#[Test]
public function checkout_session_completed_creates_order_and_records_payment()
{
$customer = User::factory()->create();
$product = $this->createProduct(150.00);
// Add to cart but DON'T call checkoutCart()
$customer->addToCart($product);
$cart = $customer->currentCart();
// Simulate checkoutSession() conversion
$cart->update([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 15000, // 150.00
'payment_status' => 'paid',
'payment_intent' => 'pi_stripe_checkout_test',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$cart->refresh();
$order = $cart->order;
$this->assertNotNull($order);
$this->assertEquals(150.00, $order->amount_paid);
$this->assertEquals(OrderStatus::PROCESSING, $order->status);
}
#[Test]
public function checkout_session_completed_creates_order_with_correct_totals()
{
$customer = User::factory()->create();
$product = $this->createProduct(75.50);
$customer->addToCart($product, 2); // 2 items = 151.00
$cart = $customer->currentCart();
$cart->update([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 15100, // 151.00
'payment_status' => 'paid',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$order = $cart->fresh()->order;
$this->assertNotNull($order);
// Order total should match cart total (in cents)
$this->assertEquals((int) $cart->getTotal() * 100, $order->amount_total);
}
#[Test]
public function checkout_session_completed_adds_payment_note_when_creating_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(50.00);
$customer->addToCart($product);
$cart = $customer->currentCart();
$cart->update([
'status' => CartStatus::CONVERTED,
'converted_at' => now(),
]);
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 5000,
'payment_status' => 'paid',
'payment_intent' => 'pi_test_payment_note',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$order = $cart->fresh()->order;
$this->assertNotNull($order);
$paymentNote = $order->notes()->where('type', OrderNote::TYPE_PAYMENT)->first();
$this->assertNotNull($paymentNote, 'Payment note should be created');
$this->assertStringContainsString('50', $paymentNote->content);
$this->assertStringContainsString('Stripe checkout', $paymentNote->content);
}
#[Test]
public function checkout_session_completed_does_not_duplicate_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(100.00);
// Use checkoutCart() which creates an order
$customer->addToCart($product);
$cart = $customer->checkoutCart();
$existingOrder = $cart->fresh()->order;
$this->assertNotNull($existingOrder);
$originalOrderId = $existingOrder->id;
// Now call webhook - should NOT create a duplicate order
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 10000,
'payment_status' => 'paid',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$cart->refresh();
// Should still be the same order
$this->assertEquals($originalOrderId, $cart->order->id);
// Should only have one order for this cart
$orderCount = Order::where('cart_id', $cart->id)->count();
$this->assertEquals(1, $orderCount);
}
#[Test]
public function checkout_session_completed_without_prior_conversion_creates_order()
{
$customer = User::factory()->create();
$product = $this->createProduct(200.00);
// Add to cart - cart is NOT converted yet (simulates edge case)
$customer->addToCart($product);
$cart = $customer->currentCart();
$this->assertEquals(CartStatus::ACTIVE, $cart->status);
$this->assertNull($cart->order);
// Webhook fires - should convert cart AND create order
$session = $this->createMockSession([
'metadata' => (object) ['cart_id' => $cart->id],
'amount_total' => 20000,
'payment_status' => 'paid',
]);
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
$cart->refresh();
// Cart should be converted
$this->assertEquals(CartStatus::CONVERTED, $cart->status);
$this->assertNotNull($cart->converted_at);
// Order should exist
$this->assertNotNull($cart->order);
$this->assertEquals(200.00, $cart->order->amount_paid);
}
} }