A prompts, I docs/readme, BF orders, R tests locations
This commit is contained in:
parent
7aeffd27a9
commit
136b7ade63
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
419
README.md
|
|
@ -9,7 +9,7 @@ A comprehensive headless e-commerce package for Laravel with stock management, S
|
|||
|
||||
## 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
|
||||
- 📦 **Advanced Stock Management** - Stock reservations, low stock alerts, and backorders
|
||||
- 💳 **Stripe Integration** - Built-in Stripe product and price synchronization
|
||||
|
|
@ -41,6 +41,14 @@ Run migrations:
|
|||
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
|
||||
|
||||
### Setup Your User Model
|
||||
|
|
@ -49,6 +57,7 @@ Add the `HasShoppingCapabilities` trait to any model that should be able to purc
|
|||
|
||||
```php
|
||||
use Blax\Shop\Traits\HasShoppingCapabilities;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
|
|
@ -60,17 +69,25 @@ class User extends Authenticatable
|
|||
|
||||
### Creating Your First Product
|
||||
|
||||
Use the provided Enums to ensure type safety and consistency.
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Enums\ProductStatus;
|
||||
use Blax\Shop\Enums\StockType;
|
||||
|
||||
$product = Product::create([
|
||||
'slug' => 'amazing-t-shirt',
|
||||
'sku' => 'TSH-001',
|
||||
'type' => 'simple',
|
||||
'type' => ProductType::SIMPLE,
|
||||
'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([
|
||||
'currency' => 'USD',
|
||||
'unit_amount' => 1999, // $19.99
|
||||
|
|
@ -78,368 +95,96 @@ $product->prices()->create([
|
|||
'is_default' => true,
|
||||
]);
|
||||
|
||||
// Manage 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(
|
||||
StockType::CLAIMED,
|
||||
10,
|
||||
1,
|
||||
from: now(),
|
||||
until: now()->addDay(),
|
||||
note: 'Booked'
|
||||
); // Claim/reserve 10 stocks
|
||||
|
||||
|
||||
// Add translated name
|
||||
$product->setLocalized('name', 'Amazing T-Shirt', 'en');
|
||||
$product->setLocalized('description', 'A comfortable cotton t-shirt', 'en');
|
||||
note: 'Reserved for Order #123'
|
||||
);
|
||||
```
|
||||
|
||||
### Working with Cart (Authenticated Users)
|
||||
|
||||
```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
|
||||
### Working with Cart
|
||||
|
||||
```php
|
||||
use Blax\Shop\Facades\Cart;
|
||||
|
||||
// Create or retrieve guest cart (uses session ID automatically)
|
||||
$guestCart = Cart::guest();
|
||||
// Add item to cart
|
||||
Cart::addToCart($product, 1);
|
||||
|
||||
// Or with specific session ID
|
||||
$guestCart = Cart::guest('custom-session-id');
|
||||
// Add item with date range (for bookings)
|
||||
Cart::addToCart($product, 1, [], now(), now()->addDay());
|
||||
|
||||
// Add items to guest cart
|
||||
$guestCart->addToCart($product, quantity: 1);
|
||||
|
||||
// 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);
|
||||
// Checkout
|
||||
$cart = Cart::getCart();
|
||||
$cart->checkout(); // Creates purchases, claims stock, etc.
|
||||
```
|
||||
|
||||
### Purchasing Products Directly
|
||||
## Advanced Usage
|
||||
|
||||
### Pool Products
|
||||
|
||||
Pool products are collections of single items (e.g., "Parking Spaces" containing "Spot A1", "Spot A2").
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
|
||||
$product = Product::find($productId);
|
||||
$user = auth()->user();
|
||||
|
||||
// Simple purchase
|
||||
$purchase = $user->purchase($product, quantity: 1);
|
||||
|
||||
// Purchase with options
|
||||
$purchase = $user->purchase($product, quantity: 2, options: [
|
||||
'price_id' => $priceId,
|
||||
'charge_id' => $paymentIntent->id,
|
||||
// Create the Pool Parent
|
||||
$pool = Product::create([
|
||||
'type' => ProductType::POOL,
|
||||
'name' => 'Parking Spaces',
|
||||
'manage_stock' => true, // Pool manages availability
|
||||
]);
|
||||
|
||||
// Check if user has purchased
|
||||
if ($user->hasPurchased($product)) {
|
||||
// Grant access
|
||||
}
|
||||
// Create Single Items
|
||||
$spot1 = Product::create([
|
||||
'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
|
||||
use Blax\Shop\Facades\Shop;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
|
||||
// Get all products
|
||||
$products = Shop::products()->get();
|
||||
$room = Product::create([
|
||||
'type' => ProductType::BOOKING,
|
||||
'name' => 'Conference Room',
|
||||
'manage_stock' => true,
|
||||
]);
|
||||
|
||||
// Get published products only
|
||||
$products = Shop::published()->get();
|
||||
|
||||
// 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);
|
||||
// Check availability
|
||||
$isAvailable = $room->availableOnDate(now(), now()->addHour());
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
To run the package tests:
|
||||
|
||||
```bash
|
||||
./vendor/bin/phpunit
|
||||
```
|
||||
|
||||
The tests use an in-memory SQLite database and Orchestra Testbench.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Product Management](docs/01-products.md)
|
||||
- [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).
|
||||
For more detailed documentation, please refer to the `docs/` directory in the repository.
|
||||
|
|
|
|||
|
|
@ -281,6 +281,7 @@ return new class extends Migration
|
|||
$table->uuid('id')->primary();
|
||||
$table->uuid('cart_id');
|
||||
$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('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete();
|
||||
$table->integer('quantity')->default(1);
|
||||
|
|
|
|||
|
|
@ -214,24 +214,28 @@ if ($product->isLowStock()) {
|
|||
}
|
||||
```
|
||||
|
||||
### Stock Reservations
|
||||
### Stock Claims (Reservations)
|
||||
|
||||
```php
|
||||
use Blax\Shop\Models\ProductStock;
|
||||
|
||||
// Reserve stock temporarily
|
||||
$reservation = $product->reserveStock(
|
||||
// Claim stock temporarily (for bookings)
|
||||
$claim = $product->claimStock(
|
||||
quantity: 2,
|
||||
reference: $cart,
|
||||
until: now()->addMinutes(15),
|
||||
from: now(),
|
||||
until: now()->addDays(3),
|
||||
note: 'Cart reservation'
|
||||
);
|
||||
|
||||
// Release reservation
|
||||
$reservation->update(['status' => 'completed']);
|
||||
// Release claim
|
||||
$product->releaseStock($cart);
|
||||
|
||||
// Get active reservations
|
||||
$reservations = $product->reservations()->get();
|
||||
// Get active claims
|
||||
$claims = $product->stocks()
|
||||
->where('type', 'claimed')
|
||||
->where('status', 'pending')
|
||||
->get();
|
||||
```
|
||||
|
||||
### Stock History
|
||||
|
|
@ -399,25 +403,27 @@ use Blax\Shop\Models\ProductAction;
|
|||
// Send email on purchase
|
||||
ProductAction::create([
|
||||
'product_id' => $product->id,
|
||||
'action_type' => 'SendWelcomeEmail',
|
||||
'event' => 'purchased',
|
||||
'class' => \App\Jobs\SendWelcomeEmail::class,
|
||||
'events' => ['purchased'],
|
||||
'parameters' => [
|
||||
'template' => 'welcome',
|
||||
'delay' => 0,
|
||||
],
|
||||
'active' => true,
|
||||
'defer' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
// Grant access on purchase
|
||||
ProductAction::create([
|
||||
'product_id' => $product->id,
|
||||
'action_type' => 'GrantCourseAccess',
|
||||
'event' => 'purchased',
|
||||
'class' => \App\Jobs\GrantCourseAccess::class,
|
||||
'events' => ['purchased'],
|
||||
'parameters' => [
|
||||
'course_id' => 123,
|
||||
],
|
||||
'active' => true,
|
||||
'defer' => true,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
```
|
||||
|
|
|
|||
|
|
@ -87,8 +87,17 @@ $user = auth()->user();
|
|||
$product = Product::find($productId);
|
||||
|
||||
try {
|
||||
// For regular products
|
||||
$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([
|
||||
'success' => true,
|
||||
'cart_item' => $cartItem,
|
||||
|
|
@ -187,20 +196,23 @@ $stats = [
|
|||
|
||||
## Cart Checkout
|
||||
|
||||
### Convert Cart to Purchases
|
||||
### Checkout Cart
|
||||
|
||||
```php
|
||||
try {
|
||||
$purchases = $user->checkoutCart();
|
||||
// Get current cart
|
||||
$cart = $user->currentCart();
|
||||
|
||||
// Checkout successful
|
||||
// Cart items are now converted to completed purchases
|
||||
// Cart is marked as converted
|
||||
// Checkout (creates purchases and order)
|
||||
$cart->checkout();
|
||||
|
||||
// Access the order
|
||||
$order = $cart->order;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'purchases' => $purchases,
|
||||
'total_items' => $purchases->count(),
|
||||
'order' => $order,
|
||||
'order_number' => $order->order_number,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
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
|
||||
|
||||
- Checkout validates stock availability for all items
|
||||
- Creates `ProductPurchase` records for each cart item
|
||||
- Decreases stock for each item
|
||||
- Triggers product actions
|
||||
- Marks cart as converted (`converted_at` timestamp)
|
||||
- Removes cart items after successful checkout
|
||||
- Stock is claimed at checkout time (not add-to-cart time for bookings)
|
||||
- Cart items remain in database but are marked as converted
|
||||
- Order is created with PENDING status by default
|
||||
|
||||
## 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
|
||||
|
||||
```php
|
||||
|
|
@ -247,55 +269,226 @@ $productPurchases = $user->purchases()
|
|||
->get();
|
||||
```
|
||||
|
||||
### Purchase Statistics
|
||||
## Order Management
|
||||
|
||||
### Get All Orders
|
||||
|
||||
```php
|
||||
$stats = $user->getPurchaseStats();
|
||||
// Get all orders
|
||||
$orders = $user->orders()->get();
|
||||
|
||||
// Returns:
|
||||
// [
|
||||
// 'total_purchases' => 15,
|
||||
// 'total_spent' => 450.00,
|
||||
// 'total_items' => 23,
|
||||
// 'cart_items' => 2,
|
||||
// 'cart_total' => 89.99,
|
||||
// ]
|
||||
// Get orders with specific status
|
||||
use Blax\Shop\Enums\OrderStatus;
|
||||
|
||||
$pendingOrders = $user->pendingOrders()->get();
|
||||
$processingOrders = $user->processingOrders()->get();
|
||||
$completedOrders = $user->completedOrders()->get();
|
||||
|
||||
// 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
|
||||
|
||||
### Refund a Purchase
|
||||
### Refund an Order
|
||||
|
||||
```php
|
||||
$purchase = ProductPurchase::find($purchaseId);
|
||||
use Blax\Shop\Enums\OrderStatus;
|
||||
|
||||
try {
|
||||
$success = $user->refundPurchase($purchase);
|
||||
$order = Order::find($orderId);
|
||||
|
||||
if ($success) {
|
||||
// Refund successful
|
||||
// Stock has been returned
|
||||
// Purchase status changed to 'refunded'
|
||||
// Product 'refunded' actions triggered
|
||||
// Mark order as refunded
|
||||
$order->update([
|
||||
'status' => OrderStatus::REFUNDED,
|
||||
'refunded_at' => now(),
|
||||
'amount_refunded' => $order->amount_total,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Purchase refunded successfully',
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => $e->getMessage()
|
||||
], 400);
|
||||
}
|
||||
// Stock will be released back from associated purchases
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Only completed purchases can be refunded
|
||||
- Stock is automatically returned to inventory
|
||||
- Product actions with event 'refunded' are triggered
|
||||
|
||||
## Cart Model
|
||||
|
||||
### Get Current Cart
|
||||
|
|
@ -321,8 +514,8 @@ $cart->last_activity_at; // Last activity timestamp
|
|||
// Get cart items
|
||||
$items = $cart->items()->get();
|
||||
|
||||
// Get cart purchases (if converted)
|
||||
$purchases = $cart->purchases()->get();
|
||||
// Get cart order (if converted)
|
||||
$order = $cart->order;
|
||||
|
||||
// Get cart customer (user)
|
||||
$customer = $cart->customer;
|
||||
|
|
@ -369,7 +562,7 @@ $cartItem = $cart->addToCart(
|
|||
```php
|
||||
$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->price_id; // Associated price ID
|
||||
$purchase->purchasable_id; // Product ID
|
||||
|
|
@ -377,9 +570,11 @@ $purchase->purchasable_type; // Product class
|
|||
$purchase->purchaser_id; // User ID
|
||||
$purchase->purchaser_type; // User class
|
||||
$purchase->quantity; // Quantity purchased
|
||||
$purchase->amount; // Total amount
|
||||
$purchase->amount_paid; // Amount paid
|
||||
$purchase->amount; // Total amount (in cents)
|
||||
$purchase->amount_paid; // Amount paid (in cents)
|
||||
$purchase->charge_id; // Payment charge ID
|
||||
$purchase->from; // Booking start date (for bookings)
|
||||
$purchase->until; // Booking end date (for bookings)
|
||||
$purchase->meta; // Additional metadata
|
||||
```
|
||||
|
||||
|
|
@ -391,35 +586,44 @@ $product = $purchase->purchasable;
|
|||
|
||||
// Get purchaser (user)
|
||||
$user = $purchase->purchaser;
|
||||
|
||||
// Get associated cart item
|
||||
$cartItem = $purchase->cartItem;
|
||||
|
||||
// Get associated order
|
||||
$order = $purchase->order;
|
||||
```
|
||||
|
||||
### Purchase Scopes
|
||||
|
||||
```php
|
||||
// Get purchases in cart
|
||||
$cartPurchases = ProductPurchase::inCart()->get();
|
||||
use Blax\Shop\Enums\PurchaseStatus;
|
||||
|
||||
// Get completed purchases
|
||||
$completed = ProductPurchase::completed()->get();
|
||||
$completed = ProductPurchase::where('status', PurchaseStatus::COMPLETED)->get();
|
||||
|
||||
// Get purchases from specific cart
|
||||
$cartPurchases = ProductPurchase::fromCart($cartId)->get();
|
||||
// Get pending purchases
|
||||
$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
|
||||
// Stock is reserved when adding to cart
|
||||
$cartItem = $user->addToCart($product, quantity: 2);
|
||||
// For booking products, stock is NOT claimed when adding to cart
|
||||
$cartItem = $user->addToCart($bookingProduct, quantity: 1, parameters: [
|
||||
'from' => Carbon::parse('2025-01-15'),
|
||||
'until' => Carbon::parse('2025-01-20'),
|
||||
]);
|
||||
|
||||
// Reservation is created automatically
|
||||
// It expires after configured time (default: 15 minutes)
|
||||
// Stock is released back when:
|
||||
// - Reservation expires
|
||||
// - Cart item is removed
|
||||
// - Cart is abandoned
|
||||
// Stock is validated and claimed during checkout
|
||||
$cart = $user->currentCart();
|
||||
$cart->checkout(); // Claims stock at this point
|
||||
|
||||
// For regular products, stock is claimed immediately when adding to cart
|
||||
$cartItem = $user->addToCart($regularProduct, quantity: 2);
|
||||
// Stock is claimed immediately for non-booking products
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
|
@ -491,10 +695,14 @@ Route::post('/checkout', function () {
|
|||
$user = auth()->user();
|
||||
|
||||
try {
|
||||
$purchases = $user->checkoutCart();
|
||||
$cart = $user->currentCart();
|
||||
$cart->checkout();
|
||||
|
||||
return redirect()->route('orders.success')
|
||||
->with('success', 'Order placed successfully!');
|
||||
// Access the created order
|
||||
$order = $cart->order;
|
||||
|
||||
return redirect()->route('orders.success', ['order' => $order->id])
|
||||
->with('success', "Order {$order->order_number} placed successfully!");
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
|
@ -504,11 +712,25 @@ Route::post('/checkout', function () {
|
|||
Route::get('/orders', function () {
|
||||
$user = auth()->user();
|
||||
|
||||
$purchases = $user->completedPurchases()
|
||||
->with('purchasable')
|
||||
$orders = $user->orders()
|
||||
->with(['purchases.purchasable'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->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'));
|
||||
});
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
```
|
||||
|
||||
When payment is successful:
|
||||
When payment is successful (handled via webhook):
|
||||
- Cart status is updated to `CONVERTED`
|
||||
- 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`
|
||||
- `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
|
||||
|
||||
|
|
@ -101,13 +104,31 @@ POST /api/shop/stripe/webhook
|
|||
|
||||
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_failed` - Logs failure
|
||||
- `charge.succeeded` - Updates purchases with charge info
|
||||
- `charge.failed` - Marks purchases as `FAILED`
|
||||
- `payment_intent.succeeded` - Updates purchases
|
||||
- `payment_intent.payment_failed` - Marks purchases as `FAILED`
|
||||
- `checkout.session.async_payment_failed` - Marks order as failed if exists
|
||||
- `checkout.session.expired` - Adds note to order
|
||||
|
||||
**Charge Events:**
|
||||
- `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
|
||||
|
||||
|
|
@ -148,14 +169,30 @@ Route::post('custom/stripe/webhook', [StripeWebhookController::class, 'handleWeb
|
|||
->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
|
||||
- `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
|
||||
|
||||
|
|
|
|||
|
|
@ -664,4 +664,3 @@ $products = Product::with([
|
|||
|
||||
- [Pool Products](./ProductTypes/02-pool-products.md) - POOL/SINGLE relations in detail
|
||||
- [Product Types](./ProductTypes/) - Understanding different product types
|
||||
- [Stock Management](./06-stock-management.md) - How stock works with relations
|
||||
|
|
|
|||
|
|
@ -316,4 +316,3 @@ $available = $product->getAvailableStock($date);
|
|||
|
||||
- [Pool Products](./02-pool-products.md) - Managing groups of booking products
|
||||
- [Product Relations](../05-product-relations.md) - How products relate to each other
|
||||
- [Stock Management](../06-stock-management.md) - Detailed stock system documentation
|
||||
|
|
|
|||
|
|
@ -359,33 +359,77 @@ if (!$parkingPool->validatePoolConfiguration()['valid']) {
|
|||
|
||||
## 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
|
||||
$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);
|
||||
|
||||
// Cart item properties:
|
||||
// - purchasable: Pool Product
|
||||
// - purchasable_id: Pool Product ID
|
||||
// - purchasable_type: Product::class
|
||||
// - product_id: Allocated Single Item ID (NEW!)
|
||||
// - quantity: 1
|
||||
// - from: 2025-01-15
|
||||
// - until: 2025-01-17
|
||||
// - 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
|
||||
$meta = $cartItem->getMeta();
|
||||
$claimedItemIds = $meta->claimed_single_items ?? [];
|
||||
$cartItem->product_id; // ID of the allocated single item
|
||||
$cartItem->purchasable_id; // ID of the pool product
|
||||
$cartItem->purchasable; // The pool product itself
|
||||
$cartItem->product; // The allocated single item
|
||||
|
||||
// Load the actual products
|
||||
$claimedItems = Product::whereIn('id', $claimedItemIds)->get();
|
||||
// Get the effective product (allocated single or purchasable)
|
||||
$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
|
||||
|
||||
```php
|
||||
|
|
@ -393,8 +437,8 @@ $cartItem->delete();
|
|||
```
|
||||
|
||||
**What happens:**
|
||||
1. System finds claimed single items from metadata
|
||||
2. Releases claims on each single item
|
||||
1. System finds allocated single item from `product_id` column
|
||||
2. Releases claims on the single item
|
||||
3. Stock becomes available again
|
||||
|
||||
## Advanced Usage
|
||||
|
|
@ -642,6 +686,22 @@ $pool->attachSingleItems($itemIds);
|
|||
// - 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
|
||||
|
||||
### 1. Lazy Loading
|
||||
|
|
@ -685,5 +745,3 @@ $pools->each(function($pool) {
|
|||
|
||||
- [Booking Products](./01-booking-products.md) - Understanding single items in pools
|
||||
- [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
|
||||
|
|
|
|||
|
|
@ -584,8 +584,11 @@ class ShopAddExampleProducts extends Command
|
|||
$prices = $productData['variation_prices'] ?? [];
|
||||
|
||||
foreach ($variations as $index => $variation) {
|
||||
$variationName = ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation;
|
||||
|
||||
$variationProduct = Product::create([
|
||||
'slug' => $product->slug . '-' . \Illuminate\Support\Str::slug($variation),
|
||||
'name' => $variationName,
|
||||
'sku' => $product->sku . '-' . strtoupper(substr($variation, 0, 3)),
|
||||
'type' => 'simple',
|
||||
'parent_id' => $product->id,
|
||||
|
|
@ -596,7 +599,7 @@ class ShopAddExampleProducts extends Command
|
|||
'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));
|
||||
$variationProduct->prices()->create([
|
||||
|
|
@ -627,8 +630,11 @@ class ShopAddExampleProducts extends Command
|
|||
}
|
||||
|
||||
foreach ($productData['grouped_items'] as $i => $item) {
|
||||
$itemName = $item['name'];
|
||||
|
||||
$childProduct = Product::create([
|
||||
'slug' => $product->slug . '-item-' . ($i + 1),
|
||||
'name' => $itemName,
|
||||
'sku' => $item['sku'],
|
||||
'type' => 'simple',
|
||||
'parent_id' => $product->id,
|
||||
|
|
@ -639,7 +645,7 @@ class ShopAddExampleProducts extends Command
|
|||
'meta' => ['grouped_item' => true, 'example' => true],
|
||||
]);
|
||||
|
||||
$childProduct->setLocalized('name', $item['name'], null, true);
|
||||
$childProduct->setLocalized('name', $itemName, null, true);
|
||||
|
||||
$childProduct->prices()->create([
|
||||
'name' => 'Default',
|
||||
|
|
@ -662,9 +668,11 @@ class ShopAddExampleProducts extends Command
|
|||
|
||||
$parkingIds = [];
|
||||
foreach ($productData['pool_items'] as $i => $item) {
|
||||
$itemName = $item['name'];
|
||||
|
||||
$parking = Product::create([
|
||||
'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($item['name']),
|
||||
'name' => $item['name'],
|
||||
'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName),
|
||||
'name' => $itemName,
|
||||
'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT),
|
||||
'type' => ProductType::BOOKING,
|
||||
'status' => ProductStatus::PUBLISHED,
|
||||
|
|
@ -675,20 +683,26 @@ class ShopAddExampleProducts extends Command
|
|||
'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
|
||||
$parking->increaseStock($item['stock']);
|
||||
|
||||
// Create price for individual parking spot
|
||||
$parking->prices()->create([
|
||||
'name' => 'Default',
|
||||
'type' => 'one_time',
|
||||
'currency' => 'EUR',
|
||||
'unit_amount' => $item['price'],
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
'billing_scheme' => 'per_unit',
|
||||
'meta' => ['example' => true],
|
||||
]);
|
||||
// Note: If price is not provided, the pool's fallback price will be used during checkout
|
||||
if (!empty($item['price'])) {
|
||||
$parking->prices()->create([
|
||||
'name' => 'Default',
|
||||
'type' => 'one_time',
|
||||
'currency' => 'EUR',
|
||||
'unit_amount' => $item['price'],
|
||||
'is_default' => true,
|
||||
'active' => true,
|
||||
'billing_scheme' => 'per_unit',
|
||||
'meta' => ['example' => true],
|
||||
]);
|
||||
}
|
||||
|
||||
$parkingIds[] = $parking->id;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,34 +141,45 @@ class StripeWebhookController
|
|||
]);
|
||||
}
|
||||
|
||||
// Record payment on the associated order
|
||||
// Get or create order from the cart
|
||||
$order = $cart->order;
|
||||
if ($order) {
|
||||
$amountPaid = (int) (($session->amount_total ?? 0) / 100);
|
||||
$currency = strtoupper($session->currency ?? $order->currency ?? 'USD');
|
||||
if (!$order) {
|
||||
// Create order from the converted cart
|
||||
$order = Order::createFromCart($cart);
|
||||
|
||||
// recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
|
||||
$order->recordPayment($amountPaid, $session->payment_intent, 'stripe', 'stripe');
|
||||
|
||||
// Add a detailed note
|
||||
$order->addNote(
|
||||
"Payment of " . Order::formatMoney($amountPaid, $currency) . " received via Stripe checkout (Session: {$session->id})",
|
||||
OrderNote::TYPE_PAYMENT
|
||||
);
|
||||
|
||||
// Mark order as processing if payment is successful
|
||||
if ($session->payment_status === 'paid' && $order->status === OrderStatus::PENDING) {
|
||||
$order->markAsProcessing('Payment received via Stripe checkout');
|
||||
}
|
||||
|
||||
Log::info('Order payment recorded via Stripe checkout', [
|
||||
Log::info('Order created from Stripe checkout session', [
|
||||
'order_id' => $order->id,
|
||||
'order_number' => $order->order_number,
|
||||
'amount' => $amountPaid,
|
||||
'currency' => $currency,
|
||||
'cart_id' => $cart->id,
|
||||
'session_id' => $session->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Record payment on the order
|
||||
$amountPaid = (int) (($session->amount_total ?? 0) / 100);
|
||||
$currency = strtoupper($session->currency ?? $order->currency ?? 'USD');
|
||||
|
||||
// recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
|
||||
$order->recordPayment($amountPaid, $session->payment_intent, 'stripe', 'stripe');
|
||||
|
||||
// Add a detailed note
|
||||
$order->addNote(
|
||||
"Payment of " . Order::formatMoney($amountPaid, $currency) . " received via Stripe checkout (Session: {$session->id})",
|
||||
OrderNote::TYPE_PAYMENT
|
||||
);
|
||||
|
||||
// Mark order as processing if payment is successful
|
||||
if ($session->payment_status === 'paid' && $order->status === OrderStatus::PENDING) {
|
||||
$order->markAsProcessing('Payment received via Stripe checkout');
|
||||
}
|
||||
|
||||
Log::info('Order payment recorded via Stripe checkout', [
|
||||
'order_id' => $order->id,
|
||||
'order_number' => $order->order_number,
|
||||
'amount' => $amountPaid,
|
||||
'currency' => $currency,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -573,8 +573,7 @@ class Cart extends Model
|
|||
|
||||
// For pool products, check if allocated by reallocatePoolItems
|
||||
if ($product instanceof Product && $product->isPool()) {
|
||||
$meta = $item->getMeta();
|
||||
$allocatedSingleItemId = $meta->allocated_single_item_id ?? null;
|
||||
$allocatedSingleItemId = $item->product_id;
|
||||
|
||||
// If this item was NOT allocated (no single assigned), skip updateDates
|
||||
// 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
|
||||
$cartItem->updateMetaKey('allocated_single_item_id', null);
|
||||
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
||||
$cartItem->update([
|
||||
'product_id' => null,
|
||||
'price' => null,
|
||||
'subtotal' => null,
|
||||
'unit_amount' => null,
|
||||
]);
|
||||
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -747,12 +746,16 @@ class Cart extends Model
|
|||
|
||||
if ($remainingFromSingle >= $neededQty) {
|
||||
// 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);
|
||||
|
||||
// Update price_id if changed
|
||||
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
|
||||
$cartItem->update(['price_id' => $singleInfo['price_id']]);
|
||||
// Legacy: update price_id if changed (now handled in the update above)
|
||||
if (false) {
|
||||
}
|
||||
|
||||
// Track usage
|
||||
|
|
@ -784,17 +787,17 @@ class Cart extends Model
|
|||
// Update the original cart item with reduced quantity
|
||||
// Also update subtotal to match the new quantity
|
||||
$newSubtotal = $cartItem->price * $qtyToAllocate;
|
||||
$cartItem->update([
|
||||
$updates = [
|
||||
'quantity' => $qtyToAllocate,
|
||||
'subtotal' => $newSubtotal,
|
||||
]);
|
||||
$cartItem->refresh(); // Ensure model reflects database state
|
||||
$cartItem->updateMetaKey('allocated_single_item_id', $single->id);
|
||||
$cartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
||||
|
||||
'product_id' => $single->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;
|
||||
} else {
|
||||
|
|
@ -814,6 +817,7 @@ class Cart extends Model
|
|||
$newCartItem = $this->items()->create([
|
||||
'purchasable_id' => $cartItem->purchasable_id,
|
||||
'purchasable_type' => $cartItem->purchasable_type,
|
||||
'product_id' => $single->id,
|
||||
'price_id' => $priceModel?->id,
|
||||
'quantity' => $qtyToAllocate,
|
||||
'price' => $pricePerUnit,
|
||||
|
|
@ -825,7 +829,6 @@ class Cart extends Model
|
|||
'until' => $until,
|
||||
]);
|
||||
|
||||
$newCartItem->updateMetaKey('allocated_single_item_id', $single->id);
|
||||
$newCartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
||||
}
|
||||
|
||||
|
|
@ -838,13 +841,13 @@ class Cart extends Model
|
|||
if ($remainingQty > 0) {
|
||||
if ($firstAllocation) {
|
||||
// Couldn't allocate anything - mark as unavailable
|
||||
$cartItem->updateMetaKey('allocated_single_item_id', null);
|
||||
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
||||
$cartItem->update([
|
||||
'product_id' => null,
|
||||
'price' => null,
|
||||
'subtotal' => null,
|
||||
'unit_amount' => null,
|
||||
]);
|
||||
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
||||
} else {
|
||||
// Partial allocation - the cart item was already updated with what we could allocate
|
||||
// The remaining quantity is lost (over-capacity)
|
||||
|
|
@ -1219,9 +1222,8 @@ class Cart extends Model
|
|||
$expectedPrice = $poolItemData['price'] ?? null;
|
||||
$expectedSingleItemId = $poolItemData['item']?->id ?? null;
|
||||
|
||||
// Get the allocated single item ID from the existing cart item's meta
|
||||
$existingMeta = $item->getMeta();
|
||||
$existingAllocatedItemId = $existingMeta->allocated_single_item_id ?? null;
|
||||
// Get the allocated single item ID from the cart item's product_id column
|
||||
$existingAllocatedItemId = $item->product_id;
|
||||
|
||||
// Only merge if:
|
||||
// 1. price_id matches (same price source)
|
||||
|
|
@ -1272,11 +1274,7 @@ class Cart extends Model
|
|||
$inCart = $this->items()
|
||||
->where('purchasable_id', $cartable->getKey())
|
||||
->where('purchasable_type', get_class($cartable))
|
||||
->get()
|
||||
->filter(function ($item) use ($single) {
|
||||
$meta = $item->getMeta();
|
||||
return isset($meta->allocated_single_item_id) && $meta->allocated_single_item_id == $single->id;
|
||||
})
|
||||
->where('product_id', $single->id)
|
||||
->sum('quantity');
|
||||
|
||||
if ($available === PHP_INT_MAX || $inCart < $available) {
|
||||
|
|
@ -1360,6 +1358,7 @@ class Cart extends Model
|
|||
$cartItem = $this->items()->create([
|
||||
'purchasable_id' => $cartable->getKey(),
|
||||
'purchasable_type' => get_class($cartable),
|
||||
'product_id' => ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) ? $poolSingleItem->id : null,
|
||||
'price_id' => $priceId,
|
||||
'quantity' => $quantity,
|
||||
'price' => $pricePerUnit, // Price per unit for the period
|
||||
|
|
@ -1371,9 +1370,8 @@ class Cart extends Model
|
|||
'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) {
|
||||
$cartItem->updateMetaKey('allocated_single_item_id', $poolSingleItem->id);
|
||||
$cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name);
|
||||
}
|
||||
|
||||
|
|
@ -1808,7 +1806,7 @@ class Cart extends Model
|
|||
* d) If the product is a pool:
|
||||
* - If the pool contains booking single items, a timespan is required.
|
||||
* - 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`).
|
||||
* - 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.
|
||||
|
|
@ -1885,13 +1883,12 @@ class Cart extends Model
|
|||
// If pool has timespan and has booking single items, claim stock from single items
|
||||
if ($from && $until && $product->hasBookingSingleItems()) {
|
||||
try {
|
||||
// Check if we have pre-allocated single items from reallocation
|
||||
$meta = $item->getMeta();
|
||||
$allocatedSingleId = $meta->allocated_single_item_id ?? null;
|
||||
// Check if we have pre-allocated single items from product_id column
|
||||
$allocatedSingleId = $item->product_id;
|
||||
|
||||
if ($allocatedSingleId) {
|
||||
// Use the pre-allocated single item
|
||||
$singleItem = Product::find($allocatedSingleId);
|
||||
// Use the pre-allocated single item from product_id
|
||||
$singleItem = $item->product;
|
||||
if (!$singleItem) {
|
||||
throw new \Exception("Allocated single item not found: {$allocatedSingleId}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class CartItem extends Model
|
|||
'cart_id',
|
||||
'purchasable_id',
|
||||
'purchasable_type',
|
||||
'product_id',
|
||||
'price_id',
|
||||
'quantity',
|
||||
'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'), 'purchasable_id');
|
||||
return $this->belongsTo(config('shop.models.product', Product::class), 'product_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;
|
||||
|
|
@ -482,12 +501,12 @@ class CartItem extends Model
|
|||
|
||||
// For pool products with an allocated single, use the allocated single's price
|
||||
// This ensures consistency when reallocatePoolItems has already assigned a specific single
|
||||
$meta = $this->getMeta();
|
||||
$allocatedSingleItemId = $meta->allocated_single_item_id ?? null;
|
||||
// The product_id column stores the actual single product being purchased
|
||||
$allocatedSingleItemId = $this->product_id;
|
||||
|
||||
if ($product->isPool() && $allocatedSingleItemId) {
|
||||
// Get the allocated single item
|
||||
$allocatedSingle = Product::find($allocatedSingleItemId);
|
||||
// Get the allocated single item from the product_id column
|
||||
$allocatedSingle = $this->product;
|
||||
|
||||
if ($allocatedSingle) {
|
||||
// Get price from the allocated single, with fallback to pool price
|
||||
|
|
|
|||
|
|
@ -786,7 +786,7 @@ trait MayBePoolProduct
|
|||
}
|
||||
|
||||
// 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
|
||||
// Exclude the specified cart item (if updating dates on existing item)
|
||||
$singleItemUsage = []; // item_id => quantity used
|
||||
|
|
@ -819,8 +819,8 @@ trait MayBePoolProduct
|
|||
}
|
||||
// else: no dates provided, count all items for progressive pricing
|
||||
|
||||
$meta = $item->getMeta();
|
||||
$allocatedItemId = $meta->allocated_single_item_id ?? null;
|
||||
// Get the allocated single item ID from the product_id column
|
||||
$allocatedItemId = $item->product_id;
|
||||
|
||||
if ($allocatedItemId) {
|
||||
$singleItemUsage[$allocatedItemId] = ($singleItemUsage[$allocatedItemId] ?? 0) + $item->quantity;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -126,13 +126,13 @@ class CartItemAvailabilityValidationTest extends TestCase
|
|||
$this->cart->addToCart($pool, 3, [], $from, $until);
|
||||
|
||||
// Manually simulate an item becoming unavailable:
|
||||
// - Remove allocation
|
||||
// - 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_id);
|
||||
unset($meta->allocated_single_item_name);
|
||||
$item->update([
|
||||
'product_id' => null,
|
||||
'meta' => json_encode($meta),
|
||||
'price' => null,
|
||||
'subtotal' => null,
|
||||
|
|
@ -245,8 +245,7 @@ class CartItemAvailabilityValidationTest extends TestCase
|
|||
|
||||
// Verify all items are allocated and ready
|
||||
foreach ($this->cart->items as $item) {
|
||||
$meta = $item->getMeta();
|
||||
$this->assertNotNull($meta->allocated_single_item_id ?? null, 'Item should be allocated');
|
||||
$this->assertNotNull($item->product_id, 'Item should have product_id allocated');
|
||||
$this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -128,7 +128,7 @@ class PoolProductPriceIdTest extends TestCase
|
|||
}
|
||||
|
||||
#[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
|
||||
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
|
||||
|
|
@ -136,28 +136,28 @@ class PoolProductPriceIdTest extends TestCase
|
|||
// Add pool to cart
|
||||
$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();
|
||||
$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);
|
||||
}
|
||||
|
||||
#[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
|
||||
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
|
||||
|
||||
// Add first pool item
|
||||
$cartItem1 = $this->cart->addToCart($this->poolProduct, 1);
|
||||
$meta1 = $cartItem1->getMeta();
|
||||
$this->assertEquals($this->singleItem1->id, $meta1->allocated_single_item_id);
|
||||
$this->assertEquals($this->singleItem1->id, $cartItem1->product_id);
|
||||
|
||||
// Add second pool item
|
||||
$cartItem2 = $this->cart->addToCart($this->poolProduct, 1);
|
||||
$meta2 = $cartItem2->getMeta();
|
||||
$this->assertEquals($this->singleItem2->id, $meta2->allocated_single_item_id);
|
||||
$this->assertEquals($this->singleItem2->id, $cartItem2->product_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
|
@ -183,13 +183,12 @@ class PoolProductPriceIdTest extends TestCase
|
|||
$this->assertEquals($poolPrice->id, $cartItem->price_id);
|
||||
$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
|
||||
$meta = $cartItem->getMeta();
|
||||
$this->assertNotNull($meta->allocated_single_item_id ?? null);
|
||||
$this->assertNotNull($cartItem->product_id);
|
||||
$this->assertTrue(
|
||||
$meta->allocated_single_item_id === $this->singleItem1->id ||
|
||||
$meta->allocated_single_item_id === $this->singleItem2->id,
|
||||
$cartItem->product_id === $this->singleItem1->id ||
|
||||
$cartItem->product_id === $this->singleItem2->id,
|
||||
'Allocated single item should be one of the pool\'s single items'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -838,7 +838,7 @@ class PoolProductionBugTest extends TestCase
|
|||
'id' => $item->id,
|
||||
'quantity' => $item->quantity,
|
||||
'price' => $item->price,
|
||||
'allocated_id' => $meta->allocated_single_item_id ?? null,
|
||||
'allocated_id' => $item->product_id,
|
||||
'allocated_name' => $meta->allocated_single_item_name ?? 'none',
|
||||
];
|
||||
}
|
||||
|
|
@ -855,7 +855,7 @@ class PoolProductionBugTest extends TestCase
|
|||
'id' => $item->id,
|
||||
'quantity' => $item->quantity,
|
||||
'price' => $item->price,
|
||||
'allocated_id' => $meta->allocated_single_item_id ?? null,
|
||||
'allocated_id' => $item->product_id,
|
||||
'allocated_name' => $meta->allocated_single_item_name ?? 'none',
|
||||
];
|
||||
$totalQuantity += $item->quantity;
|
||||
|
|
@ -870,7 +870,7 @@ class PoolProductionBugTest extends TestCase
|
|||
|
||||
// The issue: when items are merged, the allocation tracking might not work correctly
|
||||
// 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:
|
||||
// - 3 quantity allocated to single_1
|
||||
|
|
@ -879,14 +879,13 @@ class PoolProductionBugTest extends TestCase
|
|||
$single2Quantity = 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) {
|
||||
$meta = $item->getMeta();
|
||||
$allocatedId = $meta->allocated_single_item_id ?? null;
|
||||
$allocatedId = $item->product_id;
|
||||
$this->assertNotNull(
|
||||
$allocatedId,
|
||||
'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) {
|
||||
|
|
@ -958,8 +957,7 @@ class PoolProductionBugTest extends TestCase
|
|||
->get();
|
||||
$usageMap = [];
|
||||
foreach ($cartItemsAsSeenByPool as $ci) {
|
||||
$meta = $ci->getMeta();
|
||||
$allocatedId = $meta->allocated_single_item_id ?? null;
|
||||
$allocatedId = $ci->product_id;
|
||||
if ($allocatedId) {
|
||||
$usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity;
|
||||
}
|
||||
|
|
@ -1002,14 +1000,13 @@ class PoolProductionBugTest extends TestCase
|
|||
);
|
||||
|
||||
$item5 = $freshCart->addToCart($freshPool, 1);
|
||||
$meta5 = $item5->getMeta();
|
||||
|
||||
// Verify item 5 is allocated to single_3 (the only one with remaining capacity)
|
||||
$this->assertEquals(
|
||||
$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. ' .
|
||||
'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
|
||||
|
|
@ -1026,9 +1023,9 @@ class PoolProductionBugTest extends TestCase
|
|||
|
||||
$this->assertEquals(
|
||||
$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 . '). ' .
|
||||
'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
|
||||
);
|
||||
$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
|
||||
$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)
|
||||
$cart->refresh();
|
||||
|
|
@ -1090,7 +1087,7 @@ class PoolProductionBugTest extends TestCase
|
|||
|
||||
// Add second item - should get single_1 again
|
||||
$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
|
||||
$cart->refresh();
|
||||
|
|
@ -1100,7 +1097,7 @@ class PoolProductionBugTest extends TestCase
|
|||
|
||||
// Add third item - should get single_2
|
||||
$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
|
||||
$this->assertEquals(4000, $cart->fresh()->getTotal());
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -550,4 +550,207 @@ class StripeWebhookOrderTest extends TestCase
|
|||
$this->assertNotNull($failNote);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue