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
|
## Features
|
||||||
|
|
||||||
- 🛍️ **Product Management** - Simple, variable, grouped, and external products
|
- 🛍️ **Product Management** - Simple, variable, grouped, external, booking, and pool products
|
||||||
- 💰 **Multi-Currency Support** - Handle multiple currencies with ease
|
- 💰 **Multi-Currency Support** - Handle multiple currencies with ease
|
||||||
- 📦 **Advanced Stock Management** - Stock reservations, low stock alerts, and backorders
|
- 📦 **Advanced Stock Management** - Stock reservations, low stock alerts, and backorders
|
||||||
- 💳 **Stripe Integration** - Built-in Stripe product and price synchronization
|
- 💳 **Stripe Integration** - Built-in Stripe product and price synchronization
|
||||||
|
|
@ -41,6 +41,14 @@ Run migrations:
|
||||||
php artisan migrate
|
php artisan migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The main configuration file is located at `config/shop.php`. Here you can configure:
|
||||||
|
- Database table names
|
||||||
|
- Caching settings
|
||||||
|
- Stripe integration keys and settings
|
||||||
|
- Currency settings
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Setup Your User Model
|
### Setup Your User Model
|
||||||
|
|
@ -49,6 +57,7 @@ Add the `HasShoppingCapabilities` trait to any model that should be able to purc
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Blax\Shop\Traits\HasShoppingCapabilities;
|
use Blax\Shop\Traits\HasShoppingCapabilities;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
|
|
@ -60,17 +69,25 @@ class User extends Authenticatable
|
||||||
|
|
||||||
### Creating Your First Product
|
### Creating Your First Product
|
||||||
|
|
||||||
|
Use the provided Enums to ensure type safety and consistency.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Enums\ProductStatus;
|
||||||
|
use Blax\Shop\Enums\StockType;
|
||||||
|
|
||||||
$product = Product::create([
|
$product = Product::create([
|
||||||
'slug' => 'amazing-t-shirt',
|
'slug' => 'amazing-t-shirt',
|
||||||
'sku' => 'TSH-001',
|
'sku' => 'TSH-001',
|
||||||
'type' => 'simple',
|
'type' => ProductType::SIMPLE,
|
||||||
'manage_stock' => true,
|
'manage_stock' => true,
|
||||||
'status' => 'published',
|
'status' => ProductStatus::PUBLISHED,
|
||||||
|
'name' => 'Amazing T-Shirt', // Uses meta translation
|
||||||
|
'description' => 'A comfortable cotton t-shirt',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Add Price
|
||||||
$product->prices()->create([
|
$product->prices()->create([
|
||||||
'currency' => 'USD',
|
'currency' => 'USD',
|
||||||
'unit_amount' => 1999, // $19.99
|
'unit_amount' => 1999, // $19.99
|
||||||
|
|
@ -78,368 +95,96 @@ $product->prices()->create([
|
||||||
'is_default' => true,
|
'is_default' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Manage Stock
|
||||||
$product->adjustStock(StockType::INCREASE, 100); // Add 100 items to stock
|
$product->adjustStock(StockType::INCREASE, 100); // Add 100 items to stock
|
||||||
$product->adjustStock(StockType::DECREASE, 90); // Remove 100 items from stock
|
$product->adjustStock(StockType::DECREASE, 10); // Remove 10 items from stock
|
||||||
|
|
||||||
|
// Reserve Stock (e.g., for a booking)
|
||||||
$product->adjustStock(
|
$product->adjustStock(
|
||||||
StockType::CLAIMED,
|
StockType::CLAIMED,
|
||||||
10,
|
1,
|
||||||
from: now(),
|
from: now(),
|
||||||
until: now()->addDay(),
|
until: now()->addDay(),
|
||||||
note: 'Booked'
|
note: 'Reserved for Order #123'
|
||||||
); // Claim/reserve 10 stocks
|
);
|
||||||
|
|
||||||
|
|
||||||
// Add translated name
|
|
||||||
$product->setLocalized('name', 'Amazing T-Shirt', 'en');
|
|
||||||
$product->setLocalized('description', 'A comfortable cotton t-shirt', 'en');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Working with Cart (Authenticated Users)
|
### Working with Cart
|
||||||
|
|
||||||
```php
|
|
||||||
use Blax\Shop\Facades\Cart;
|
|
||||||
use Blax\Shop\Models\Product;
|
|
||||||
|
|
||||||
$product = Product::find($productId);
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
// Add to cart (via facade)
|
|
||||||
Cart::add($product, quantity: 2);
|
|
||||||
|
|
||||||
// Or via user trait
|
|
||||||
$cartItem = $user->addToCart($product, quantity: 1);
|
|
||||||
|
|
||||||
// Get cart totals
|
|
||||||
$total = Cart::total();
|
|
||||||
$itemCount = Cart::itemCount();
|
|
||||||
|
|
||||||
// Check if cart is empty
|
|
||||||
if (Cart::isEmpty()) {
|
|
||||||
// Cart is empty
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from cart
|
|
||||||
Cart::remove($product);
|
|
||||||
|
|
||||||
// Clear entire cart
|
|
||||||
Cart::clear();
|
|
||||||
|
|
||||||
// Checkout cart
|
|
||||||
$completedPurchases = Cart::checkout();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Working with Guest Carts
|
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Blax\Shop\Facades\Cart;
|
use Blax\Shop\Facades\Cart;
|
||||||
|
|
||||||
// Create or retrieve guest cart (uses session ID automatically)
|
// Add item to cart
|
||||||
$guestCart = Cart::guest();
|
Cart::addToCart($product, 1);
|
||||||
|
|
||||||
// Or with specific session ID
|
// Add item with date range (for bookings)
|
||||||
$guestCart = Cart::guest('custom-session-id');
|
Cart::addToCart($product, 1, [], now(), now()->addDay());
|
||||||
|
|
||||||
// Add items to guest cart
|
// Checkout
|
||||||
$guestCart->addToCart($product, quantity: 1);
|
$cart = Cart::getCart();
|
||||||
|
$cart->checkout(); // Creates purchases, claims stock, etc.
|
||||||
// Get guest cart totals
|
|
||||||
$total = Cart::total($guestCart);
|
|
||||||
$itemCount = Cart::itemCount($guestCart);
|
|
||||||
|
|
||||||
// Check if guest cart is empty
|
|
||||||
if (Cart::isEmpty($guestCart)) {
|
|
||||||
// Cart is empty
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear guest cart
|
|
||||||
Cart::clear($guestCart);
|
|
||||||
|
|
||||||
// Convert guest cart to user cart on login
|
|
||||||
$guestCart->convertToUserCart($user);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Purchasing Products Directly
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Pool Products
|
||||||
|
|
||||||
|
Pool products are collections of single items (e.g., "Parking Spaces" containing "Spot A1", "Spot A2").
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
|
||||||
$product = Product::find($productId);
|
// Create the Pool Parent
|
||||||
$user = auth()->user();
|
$pool = Product::create([
|
||||||
|
'type' => ProductType::POOL,
|
||||||
// Simple purchase
|
'name' => 'Parking Spaces',
|
||||||
$purchase = $user->purchase($product, quantity: 1);
|
'manage_stock' => true, // Pool manages availability
|
||||||
|
|
||||||
// Purchase with options
|
|
||||||
$purchase = $user->purchase($product, quantity: 2, options: [
|
|
||||||
'price_id' => $priceId,
|
|
||||||
'charge_id' => $paymentIntent->id,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if user has purchased
|
// Create Single Items
|
||||||
if ($user->hasPurchased($product)) {
|
$spot1 = Product::create([
|
||||||
// Grant access
|
'type' => ProductType::BOOKING,
|
||||||
}
|
'name' => 'Spot A1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$spot2 = Product::create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'name' => 'Spot A2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Attach Singles to Pool
|
||||||
|
$pool->attachSingleItems([$spot1->id, $spot2->id]);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Shop Facade
|
### Booking Products
|
||||||
|
|
||||||
|
Booking products are time-based and require `from` and `until` dates when adding to cart.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Blax\Shop\Facades\Shop;
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
|
||||||
// Get all products
|
$room = Product::create([
|
||||||
$products = Shop::products()->get();
|
'type' => ProductType::BOOKING,
|
||||||
|
'name' => 'Conference Room',
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
// Get published products only
|
// Check availability
|
||||||
$products = Shop::published()->get();
|
$isAvailable = $room->availableOnDate(now(), now()->addHour());
|
||||||
|
|
||||||
// Get products in stock
|
|
||||||
$products = Shop::inStock()->get();
|
|
||||||
|
|
||||||
// Get featured products
|
|
||||||
$featured = Shop::featured()->get();
|
|
||||||
|
|
||||||
// Search products
|
|
||||||
$results = Shop::search('t-shirt')->get();
|
|
||||||
|
|
||||||
// Check stock availability
|
|
||||||
if (Shop::checkStock($product, quantity: 5)) {
|
|
||||||
// Sufficient stock available
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get available stock
|
|
||||||
$available = Shop::getAvailableStock($product);
|
|
||||||
|
|
||||||
// Check if product is on sale
|
|
||||||
if (Shop::isOnSale($product)) {
|
|
||||||
// Show sale badge
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get configuration
|
|
||||||
$currency = Shop::currency(); // USD
|
|
||||||
$config = Shop::config('cart.expire_after_days', 30);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
To run the package tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests use an in-memory SQLite database and Orchestra Testbench.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Product Management](docs/01-products.md)
|
For more detailed documentation, please refer to the `docs/` directory in the repository.
|
||||||
- [Stripe Integration](docs/02-stripe.md)
|
|
||||||
- [Purchasing Products](docs/03-purchasing.md)
|
|
||||||
- [Subscriptions](docs/04-subscriptions.md)
|
|
||||||
- [Stock Management](docs/05-stock.md)
|
|
||||||
- [API Usage](docs/06-api.md)
|
|
||||||
|
|
||||||
## Models
|
|
||||||
|
|
||||||
The package includes the following models:
|
|
||||||
|
|
||||||
- **Product** - Main product model with support for simple, variable, grouped, and external products
|
|
||||||
- **ProductPrice** - Multi-currency pricing with sale prices and subscription support
|
|
||||||
- **ProductCategory** - Hierarchical product categories
|
|
||||||
- **ProductStock** - Advanced stock management with reservations and logging
|
|
||||||
- **ProductAttribute** - Product attributes (size, color, material, etc.)
|
|
||||||
- **ProductPurchase** - Purchase records and history
|
|
||||||
- **ProductAction** - Custom actions triggered by product events
|
|
||||||
- **ProductActionRun** - Execution logs for product actions
|
|
||||||
- **Cart** - Shopping cart for authenticated users and guests
|
|
||||||
- **CartItem** - Individual items in a cart
|
|
||||||
- **PaymentMethod** - Saved payment methods
|
|
||||||
- **PaymentProviderIdentity** - Links users to payment providers (Stripe, etc.)
|
|
||||||
|
|
||||||
## Traits
|
|
||||||
|
|
||||||
Available traits for your models:
|
|
||||||
|
|
||||||
- **HasShoppingCapabilities** - Complete shopping functionality (cart + purchases)
|
|
||||||
- **HasCart** - Cart management functionality only
|
|
||||||
- **HasPaymentMethods** - Payment method management
|
|
||||||
- **HasStripeAccount** - Stripe integration for users
|
|
||||||
- **HasPrices** - Price management (for Product model)
|
|
||||||
- **HasStocks** - Stock management (for Product model)
|
|
||||||
- **HasCategories** - Category relationships (for Product model)
|
|
||||||
- **HasProductRelations** - Related products, upsells, cross-sells
|
|
||||||
- **HasChargingOptions** - Payment processing capabilities
|
|
||||||
|
|
||||||
## Facades
|
|
||||||
|
|
||||||
The package provides two facades for cleaner API access:
|
|
||||||
|
|
||||||
### Shop Facade
|
|
||||||
|
|
||||||
```php
|
|
||||||
use Blax\Shop\Facades\Shop;
|
|
||||||
|
|
||||||
Shop::products() // Get product query builder
|
|
||||||
Shop::product($id) // Find product by ID
|
|
||||||
Shop::categories() // Get categories query builder
|
|
||||||
Shop::inStock() // Get in-stock products
|
|
||||||
Shop::featured() // Get featured products
|
|
||||||
Shop::published() // Get published products
|
|
||||||
Shop::search($query) // Search products
|
|
||||||
Shop::checkStock($product, $qty) // Check stock availability
|
|
||||||
Shop::getAvailableStock($product) // Get available stock quantity
|
|
||||||
Shop::isOnSale($product) // Check if product is on sale
|
|
||||||
Shop::config($key, $default) // Get shop configuration
|
|
||||||
Shop::currency() // Get default currency
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cart Facade
|
|
||||||
|
|
||||||
```php
|
|
||||||
use Blax\Shop\Facades\Cart;
|
|
||||||
|
|
||||||
Cart::current() // Get current user's cart
|
|
||||||
Cart::guest($sessionId) // Get/create guest cart
|
|
||||||
Cart::forUser($user) // Get cart for specific user
|
|
||||||
Cart::find($cartId) // Find cart by ID
|
|
||||||
Cart::add($product, $qty, $params) // Add item to cart
|
|
||||||
Cart::remove($product, $qty) // Remove item from cart
|
|
||||||
Cart::update($cartItem, $qty) // Update cart item quantity
|
|
||||||
Cart::clear($cart) // Clear cart items
|
|
||||||
Cart::checkout($cart) // Checkout cart
|
|
||||||
Cart::total($cart) // Get cart total
|
|
||||||
Cart::itemCount($cart) // Get item count
|
|
||||||
Cart::items($cart) // Get cart items
|
|
||||||
Cart::isEmpty($cart) // Check if cart is empty
|
|
||||||
Cart::isExpired($cart) // Check if cart is expired
|
|
||||||
Cart::isConverted($cart) // Check if cart was converted
|
|
||||||
Cart::unpaidAmount($cart) // Get unpaid amount
|
|
||||||
Cart::paidAmount($cart) // Get paid amount
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The `config/shop.php` file contains all configuration options:
|
|
||||||
|
|
||||||
```php
|
|
||||||
return [
|
|
||||||
// Table names (customizable for multi-tenancy)
|
|
||||||
'tables' => [
|
|
||||||
'products' => 'products',
|
|
||||||
'product_categories' => 'product_categories',
|
|
||||||
'product_prices' => 'product_prices',
|
|
||||||
'product_stocks' => 'product_stocks',
|
|
||||||
'product_attributes' => 'product_attributes',
|
|
||||||
'product_purchases' => 'product_purchases',
|
|
||||||
'product_actions' => 'product_actions',
|
|
||||||
'product_action_runs' => 'product_action_runs',
|
|
||||||
'product_relations' => 'product_relations',
|
|
||||||
'carts' => 'carts',
|
|
||||||
'cart_items' => 'cart_items',
|
|
||||||
'cart_discounts' => 'cart_discounts',
|
|
||||||
'payment_methods' => 'payment_methods',
|
|
||||||
'payment_provider_identities' => 'payment_provider_identities',
|
|
||||||
],
|
|
||||||
|
|
||||||
// Model classes (allow overriding)
|
|
||||||
'models' => [
|
|
||||||
'product' => \Blax\Shop\Models\Product::class,
|
|
||||||
'product_price' => \Blax\Shop\Models\ProductPrice::class,
|
|
||||||
'product_category' => \Blax\Shop\Models\ProductCategory::class,
|
|
||||||
'product_stock' => \Blax\Shop\Models\ProductStock::class,
|
|
||||||
'product_attribute' => \Blax\Shop\Models\ProductAttribute::class,
|
|
||||||
'product_purchase' => \Blax\Shop\Models\ProductPurchase::class,
|
|
||||||
'cart' => \Blax\Shop\Models\Cart::class,
|
|
||||||
'cart_item' => \Blax\Shop\Models\CartItem::class,
|
|
||||||
'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class,
|
|
||||||
'payment_method' => \Blax\Shop\Models\PaymentMethod::class,
|
|
||||||
],
|
|
||||||
|
|
||||||
// API Routes
|
|
||||||
'routes' => [
|
|
||||||
'enabled' => true,
|
|
||||||
'prefix' => 'api/shop',
|
|
||||||
'middleware' => ['api'],
|
|
||||||
'name_prefix' => 'shop.',
|
|
||||||
],
|
|
||||||
|
|
||||||
// Stock management
|
|
||||||
'stock' => [
|
|
||||||
'track_inventory' => true,
|
|
||||||
'allow_backorders' => false,
|
|
||||||
'low_stock_threshold' => 5,
|
|
||||||
'log_changes' => true,
|
|
||||||
'auto_release_expired' => true,
|
|
||||||
],
|
|
||||||
|
|
||||||
// Product actions
|
|
||||||
'actions' => [
|
|
||||||
'path' => app_path('Jobs/ProductAction'),
|
|
||||||
'namespace' => 'App\\Jobs\\ProductAction',
|
|
||||||
'auto_discover' => true,
|
|
||||||
],
|
|
||||||
|
|
||||||
// Stripe integration
|
|
||||||
'stripe' => [
|
|
||||||
'enabled' => env('SHOP_STRIPE_ENABLED', false),
|
|
||||||
'sync_prices' => true,
|
|
||||||
],
|
|
||||||
|
|
||||||
// Cache configuration
|
|
||||||
'cache' => [
|
|
||||||
'enabled' => env('SHOP_CACHE_ENABLED', true),
|
|
||||||
'ttl' => 3600,
|
|
||||||
'prefix' => 'shop:',
|
|
||||||
],
|
|
||||||
|
|
||||||
// Cart configuration
|
|
||||||
'cart' => [
|
|
||||||
'expire_after_days' => 30,
|
|
||||||
'auto_cleanup' => true,
|
|
||||||
'merge_on_login' => true,
|
|
||||||
],
|
|
||||||
|
|
||||||
// API response format
|
|
||||||
'api' => [
|
|
||||||
'include_meta' => true,
|
|
||||||
'wrap_response' => true,
|
|
||||||
'response_key' => 'data',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
### Add Example Products
|
|
||||||
|
|
||||||
Create example products for testing and demonstration purposes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create 2 products of each type (default)
|
|
||||||
php artisan shop:add-example-products
|
|
||||||
|
|
||||||
# Create 5 products of each type
|
|
||||||
php artisan shop:add-example-products --count=5
|
|
||||||
|
|
||||||
# Clean existing example products first
|
|
||||||
php artisan shop:add-example-products --clean
|
|
||||||
```
|
|
||||||
|
|
||||||
This command creates:
|
|
||||||
- ✅ All 4 product types (simple, variable, grouped, external)
|
|
||||||
- ✅ Product categories
|
|
||||||
- ✅ Product attributes (material, size, color, etc.)
|
|
||||||
- ✅ Multiple pricing options (multi-currency, subscriptions)
|
|
||||||
- ✅ Example product actions (email notifications, stats updates)
|
|
||||||
- ✅ Variations for variable products
|
|
||||||
- ✅ Child products for grouped products
|
|
||||||
- ✅ Realistic data using Faker
|
|
||||||
|
|
||||||
### Reinstall Shop Tables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# With confirmation
|
|
||||||
php artisan shop:reinstall
|
|
||||||
|
|
||||||
# Force without confirmation
|
|
||||||
php artisan shop:reinstall --force
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ **Warning:** This will delete all shop data!
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues and questions, please use the [GitHub issue tracker](https://github.com/blax/laravel-shop/issues).
|
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,7 @@ return new class extends Migration
|
||||||
$table->uuid('id')->primary();
|
$table->uuid('id')->primary();
|
||||||
$table->uuid('cart_id');
|
$table->uuid('cart_id');
|
||||||
$table->uuidMorphs('purchasable');
|
$table->uuidMorphs('purchasable');
|
||||||
|
$table->foreignUuid('product_id')->nullable()->constrained(config('shop.tables.products', 'products'))->nullOnDelete();
|
||||||
$table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete();
|
$table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete();
|
||||||
$table->foreignUuid('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete();
|
$table->foreignUuid('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete();
|
||||||
$table->integer('quantity')->default(1);
|
$table->integer('quantity')->default(1);
|
||||||
|
|
|
||||||
|
|
@ -214,24 +214,28 @@ if ($product->isLowStock()) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stock Reservations
|
### Stock Claims (Reservations)
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Blax\Shop\Models\ProductStock;
|
use Blax\Shop\Models\ProductStock;
|
||||||
|
|
||||||
// Reserve stock temporarily
|
// Claim stock temporarily (for bookings)
|
||||||
$reservation = $product->reserveStock(
|
$claim = $product->claimStock(
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
reference: $cart,
|
reference: $cart,
|
||||||
until: now()->addMinutes(15),
|
from: now(),
|
||||||
|
until: now()->addDays(3),
|
||||||
note: 'Cart reservation'
|
note: 'Cart reservation'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Release reservation
|
// Release claim
|
||||||
$reservation->update(['status' => 'completed']);
|
$product->releaseStock($cart);
|
||||||
|
|
||||||
// Get active reservations
|
// Get active claims
|
||||||
$reservations = $product->reservations()->get();
|
$claims = $product->stocks()
|
||||||
|
->where('type', 'claimed')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->get();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stock History
|
### Stock History
|
||||||
|
|
@ -399,25 +403,27 @@ use Blax\Shop\Models\ProductAction;
|
||||||
// Send email on purchase
|
// Send email on purchase
|
||||||
ProductAction::create([
|
ProductAction::create([
|
||||||
'product_id' => $product->id,
|
'product_id' => $product->id,
|
||||||
'action_type' => 'SendWelcomeEmail',
|
'class' => \App\Jobs\SendWelcomeEmail::class,
|
||||||
'event' => 'purchased',
|
'events' => ['purchased'],
|
||||||
'parameters' => [
|
'parameters' => [
|
||||||
'template' => 'welcome',
|
'template' => 'welcome',
|
||||||
'delay' => 0,
|
'delay' => 0,
|
||||||
],
|
],
|
||||||
'active' => true,
|
'active' => true,
|
||||||
|
'defer' => true,
|
||||||
'sort_order' => 1,
|
'sort_order' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Grant access on purchase
|
// Grant access on purchase
|
||||||
ProductAction::create([
|
ProductAction::create([
|
||||||
'product_id' => $product->id,
|
'product_id' => $product->id,
|
||||||
'action_type' => 'GrantCourseAccess',
|
'class' => \App\Jobs\GrantCourseAccess::class,
|
||||||
'event' => 'purchased',
|
'events' => ['purchased'],
|
||||||
'parameters' => [
|
'parameters' => [
|
||||||
'course_id' => 123,
|
'course_id' => 123,
|
||||||
],
|
],
|
||||||
'active' => true,
|
'active' => true,
|
||||||
|
'defer' => true,
|
||||||
'sort_order' => 2,
|
'sort_order' => 2,
|
||||||
]);
|
]);
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,17 @@ $user = auth()->user();
|
||||||
$product = Product::find($productId);
|
$product = Product::find($productId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// For regular products
|
||||||
$cartItem = $user->addToCart($product, quantity: 1);
|
$cartItem = $user->addToCart($product, quantity: 1);
|
||||||
|
|
||||||
|
// For booking products (requires dates)
|
||||||
|
$from = Carbon::parse('2025-01-15');
|
||||||
|
$until = Carbon::parse('2025-01-20');
|
||||||
|
$cartItem = $user->addToCart($product, quantity: 1, parameters: [
|
||||||
|
'from' => $from,
|
||||||
|
'until' => $until,
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'cart_item' => $cartItem,
|
'cart_item' => $cartItem,
|
||||||
|
|
@ -187,20 +196,23 @@ $stats = [
|
||||||
|
|
||||||
## Cart Checkout
|
## Cart Checkout
|
||||||
|
|
||||||
### Convert Cart to Purchases
|
### Checkout Cart
|
||||||
|
|
||||||
```php
|
```php
|
||||||
try {
|
try {
|
||||||
$purchases = $user->checkoutCart();
|
// Get current cart
|
||||||
|
$cart = $user->currentCart();
|
||||||
|
|
||||||
// Checkout successful
|
// Checkout (creates purchases and order)
|
||||||
// Cart items are now converted to completed purchases
|
$cart->checkout();
|
||||||
// Cart is marked as converted
|
|
||||||
|
// Access the order
|
||||||
|
$order = $cart->order;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'purchases' => $purchases,
|
'order' => $order,
|
||||||
'total_items' => $purchases->count(),
|
'order_number' => $order->order_number,
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -209,28 +221,38 @@ try {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### What Happens During Checkout
|
||||||
|
|
||||||
|
1. **Validates Cart**
|
||||||
|
- Checks that cart is not empty
|
||||||
|
- Validates all items have required information
|
||||||
|
- For booking products: validates dates are set
|
||||||
|
|
||||||
|
2. **Claims Stock**
|
||||||
|
- Claims stock for booking/pool products
|
||||||
|
- Validates stock availability
|
||||||
|
|
||||||
|
3. **Creates Order**
|
||||||
|
- Generates order number
|
||||||
|
- Creates Order record linked to cart
|
||||||
|
- Copies cart total to order amounts
|
||||||
|
|
||||||
|
4. **Creates Purchases**
|
||||||
|
- Creates ProductPurchase records for each cart item
|
||||||
|
- Links purchases to order
|
||||||
|
|
||||||
|
5. **Converts Cart**
|
||||||
|
- Marks cart as CONVERTED
|
||||||
|
- Sets `converted_at` timestamp
|
||||||
|
|
||||||
### Important Notes
|
### Important Notes
|
||||||
|
|
||||||
- Checkout validates stock availability for all items
|
- Stock is claimed at checkout time (not add-to-cart time for bookings)
|
||||||
- Creates `ProductPurchase` records for each cart item
|
- Cart items remain in database but are marked as converted
|
||||||
- Decreases stock for each item
|
- Order is created with PENDING status by default
|
||||||
- Triggers product actions
|
|
||||||
- Marks cart as converted (`converted_at` timestamp)
|
|
||||||
- Removes cart items after successful checkout
|
|
||||||
|
|
||||||
## Purchase History
|
## Purchase History
|
||||||
|
|
||||||
### Check if User Purchased Product
|
|
||||||
|
|
||||||
```php
|
|
||||||
$product = Product::find($productId);
|
|
||||||
|
|
||||||
if ($user->hasPurchased($product)) {
|
|
||||||
// User has purchased this product
|
|
||||||
echo "You own this product!";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get All Purchases
|
### Get All Purchases
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
|
@ -247,55 +269,226 @@ $productPurchases = $user->purchases()
|
||||||
->get();
|
->get();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Purchase Statistics
|
## Order Management
|
||||||
|
|
||||||
|
### Get All Orders
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$stats = $user->getPurchaseStats();
|
// Get all orders
|
||||||
|
$orders = $user->orders()->get();
|
||||||
|
|
||||||
// Returns:
|
// Get orders with specific status
|
||||||
// [
|
use Blax\Shop\Enums\OrderStatus;
|
||||||
// 'total_purchases' => 15,
|
|
||||||
// 'total_spent' => 450.00,
|
$pendingOrders = $user->pendingOrders()->get();
|
||||||
// 'total_items' => 23,
|
$processingOrders = $user->processingOrders()->get();
|
||||||
// 'cart_items' => 2,
|
$completedOrders = $user->completedOrders()->get();
|
||||||
// 'cart_total' => 89.99,
|
|
||||||
// ]
|
// Get active orders (not completed/cancelled/refunded)
|
||||||
|
$activeOrders = $user->activeOrders()->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Order Status Flow
|
||||||
|
|
||||||
|
Orders progress through these statuses:
|
||||||
|
|
||||||
|
1. **PENDING** - Order received but awaiting payment confirmation
|
||||||
|
2. **PROCESSING** - Payment received and order is being processed
|
||||||
|
3. **ON_HOLD** - Order on hold, awaiting further action
|
||||||
|
4. **IN_PREPARATION** - Order being prepared (packing, manufacturing)
|
||||||
|
5. **READY_FOR_PICKUP** - Order ready for pickup (for local pickup orders)
|
||||||
|
6. **SHIPPED** - Order has been shipped and is in transit
|
||||||
|
7. **DELIVERED** - Order delivered to customer
|
||||||
|
8. **COMPLETED** - Order complete, all actions fulfilled
|
||||||
|
9. **CANCELLED** - Order was cancelled
|
||||||
|
10. **REFUNDED** - Order was refunded
|
||||||
|
11. **FAILED** - Payment or processing failed
|
||||||
|
|
||||||
|
### Get Order by Number
|
||||||
|
|
||||||
|
```php
|
||||||
|
$order = $user->findOrderByNumber('ORD-2025-0001');
|
||||||
|
|
||||||
|
if ($order) {
|
||||||
|
echo "Order found: {$order->order_number}";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Order Details
|
||||||
|
|
||||||
|
```php
|
||||||
|
$order = Order::find($orderId);
|
||||||
|
|
||||||
|
// Order properties
|
||||||
|
$order->order_number; // Unique order number
|
||||||
|
$order->status; // OrderStatus enum
|
||||||
|
$order->amount_total; // Total amount (in cents)
|
||||||
|
$order->amount_paid; // Amount paid (in cents)
|
||||||
|
$order->amount_subtotal; // Subtotal before tax/shipping
|
||||||
|
$order->amount_tax; // Tax amount
|
||||||
|
$order->amount_shipping; // Shipping cost
|
||||||
|
$order->amount_discount; // Discount applied
|
||||||
|
$order->amount_refunded; // Amount refunded
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
$order->created_at; // When order was created
|
||||||
|
$order->paid_at; // When payment was received
|
||||||
|
$order->shipped_at; // When order was shipped
|
||||||
|
$order->delivered_at; // When order was delivered
|
||||||
|
$order->completed_at; // When order was completed
|
||||||
|
$order->cancelled_at; // When order was cancelled
|
||||||
|
$order->refunded_at; // When order was refunded
|
||||||
|
|
||||||
|
// Additional info
|
||||||
|
$order->payment_method; // Payment method used
|
||||||
|
$order->payment_provider; // Payment provider (e.g., 'stripe')
|
||||||
|
$order->payment_reference; // Provider reference ID
|
||||||
|
$order->billing_address; // Billing address object
|
||||||
|
$order->shipping_address; // Shipping address object
|
||||||
|
$order->customer_note; // Customer's note
|
||||||
|
$order->internal_note; // Internal staff note
|
||||||
|
```
|
||||||
|
|
||||||
|
### Order Relationships
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Get order customer
|
||||||
|
$customer = $order->customer;
|
||||||
|
|
||||||
|
// Get order purchases (line items)
|
||||||
|
$purchases = $order->purchases()->get();
|
||||||
|
|
||||||
|
// Get original cart
|
||||||
|
$cart = $order->cart;
|
||||||
|
|
||||||
|
// Get order notes
|
||||||
|
$notes = $order->notes()->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Order Statistics
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Total spent across all orders
|
||||||
|
$totalSpent = $user->total_spent; // Accessor in cents
|
||||||
|
|
||||||
|
// Number of orders
|
||||||
|
$orderCount = $user->order_count;
|
||||||
|
|
||||||
|
// Number of completed orders
|
||||||
|
$completedCount = $user->completed_order_count;
|
||||||
|
|
||||||
|
// Check if user has any orders
|
||||||
|
if ($user->hasOrders()) {
|
||||||
|
echo "Customer has placed orders";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has active orders
|
||||||
|
if ($user->hasActiveOrders()) {
|
||||||
|
echo "Customer has orders in progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest order
|
||||||
|
$latestOrder = $user->latestOrder();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Orders by Date
|
||||||
|
|
||||||
|
```php
|
||||||
|
$from = Carbon::parse('2025-01-01');
|
||||||
|
$to = Carbon::parse('2025-12-31');
|
||||||
|
|
||||||
|
$ordersThisYear = $user->ordersBetween($from, $to)->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Order Payment Status
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Check if order is paid
|
||||||
|
if ($order->is_paid) {
|
||||||
|
echo "Order has been paid";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if fully paid
|
||||||
|
if ($order->is_fully_paid) {
|
||||||
|
echo "Order is fully paid";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get outstanding amount
|
||||||
|
$outstanding = $order->amount_outstanding; // In cents
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Order Status
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Blax\Shop\Enums\OrderStatus;
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
$order->update(['status' => OrderStatus::PROCESSING]);
|
||||||
|
|
||||||
|
// Mark as shipped
|
||||||
|
$order->update([
|
||||||
|
'status' => OrderStatus::SHIPPED,
|
||||||
|
'shipped_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mark as delivered
|
||||||
|
$order->update([
|
||||||
|
'status' => OrderStatus::DELIVERED,
|
||||||
|
'delivered_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
$order->update([
|
||||||
|
'status' => OrderStatus::COMPLETED,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Order Notes
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Blax\Shop\Models\OrderNote;
|
||||||
|
|
||||||
|
// Add customer-visible note
|
||||||
|
OrderNote::create([
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'content' => 'Your order has been shipped!',
|
||||||
|
'is_customer_note' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add internal note
|
||||||
|
OrderNote::create([
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'content' => 'Customer requested gift wrapping',
|
||||||
|
'is_customer_note' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get all notes
|
||||||
|
$allNotes = $order->notes()->get();
|
||||||
|
|
||||||
|
// Get customer-visible notes only
|
||||||
|
$customerNotes = $order->notes()->where('is_customer_note', true)->get();
|
||||||
```
|
```
|
||||||
|
|
||||||
## Refunds
|
## Refunds
|
||||||
|
|
||||||
### Refund a Purchase
|
### Refund an Order
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$purchase = ProductPurchase::find($purchaseId);
|
use Blax\Shop\Enums\OrderStatus;
|
||||||
|
|
||||||
try {
|
$order = Order::find($orderId);
|
||||||
$success = $user->refundPurchase($purchase);
|
|
||||||
|
// Mark order as refunded
|
||||||
if ($success) {
|
$order->update([
|
||||||
// Refund successful
|
'status' => OrderStatus::REFUNDED,
|
||||||
// Stock has been returned
|
'refunded_at' => now(),
|
||||||
// Purchase status changed to 'refunded'
|
'amount_refunded' => $order->amount_total,
|
||||||
// Product 'refunded' actions triggered
|
]);
|
||||||
|
|
||||||
return response()->json([
|
// Stock will be released back from associated purchases
|
||||||
'success' => true,
|
|
||||||
'message' => 'Purchase refunded successfully',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return response()->json([
|
|
||||||
'error' => $e->getMessage()
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Important Notes
|
|
||||||
|
|
||||||
- Only completed purchases can be refunded
|
|
||||||
- Stock is automatically returned to inventory
|
|
||||||
- Product actions with event 'refunded' are triggered
|
|
||||||
|
|
||||||
## Cart Model
|
## Cart Model
|
||||||
|
|
||||||
### Get Current Cart
|
### Get Current Cart
|
||||||
|
|
@ -321,8 +514,8 @@ $cart->last_activity_at; // Last activity timestamp
|
||||||
// Get cart items
|
// Get cart items
|
||||||
$items = $cart->items()->get();
|
$items = $cart->items()->get();
|
||||||
|
|
||||||
// Get cart purchases (if converted)
|
// Get cart order (if converted)
|
||||||
$purchases = $cart->purchases()->get();
|
$order = $cart->order;
|
||||||
|
|
||||||
// Get cart customer (user)
|
// Get cart customer (user)
|
||||||
$customer = $cart->customer;
|
$customer = $cart->customer;
|
||||||
|
|
@ -369,7 +562,7 @@ $cartItem = $cart->addToCart(
|
||||||
```php
|
```php
|
||||||
$purchase = ProductPurchase::find($purchaseId);
|
$purchase = ProductPurchase::find($purchaseId);
|
||||||
|
|
||||||
$purchase->status; // cart, pending, unpaid, completed, refunded
|
$purchase->status; // pending, unpaid, completed, refunded, failed
|
||||||
$purchase->cart_id; // Associated cart ID
|
$purchase->cart_id; // Associated cart ID
|
||||||
$purchase->price_id; // Associated price ID
|
$purchase->price_id; // Associated price ID
|
||||||
$purchase->purchasable_id; // Product ID
|
$purchase->purchasable_id; // Product ID
|
||||||
|
|
@ -377,9 +570,11 @@ $purchase->purchasable_type; // Product class
|
||||||
$purchase->purchaser_id; // User ID
|
$purchase->purchaser_id; // User ID
|
||||||
$purchase->purchaser_type; // User class
|
$purchase->purchaser_type; // User class
|
||||||
$purchase->quantity; // Quantity purchased
|
$purchase->quantity; // Quantity purchased
|
||||||
$purchase->amount; // Total amount
|
$purchase->amount; // Total amount (in cents)
|
||||||
$purchase->amount_paid; // Amount paid
|
$purchase->amount_paid; // Amount paid (in cents)
|
||||||
$purchase->charge_id; // Payment charge ID
|
$purchase->charge_id; // Payment charge ID
|
||||||
|
$purchase->from; // Booking start date (for bookings)
|
||||||
|
$purchase->until; // Booking end date (for bookings)
|
||||||
$purchase->meta; // Additional metadata
|
$purchase->meta; // Additional metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -391,35 +586,44 @@ $product = $purchase->purchasable;
|
||||||
|
|
||||||
// Get purchaser (user)
|
// Get purchaser (user)
|
||||||
$user = $purchase->purchaser;
|
$user = $purchase->purchaser;
|
||||||
|
|
||||||
|
// Get associated cart item
|
||||||
|
$cartItem = $purchase->cartItem;
|
||||||
|
|
||||||
|
// Get associated order
|
||||||
|
$order = $purchase->order;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Purchase Scopes
|
### Purchase Scopes
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Get purchases in cart
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
$cartPurchases = ProductPurchase::inCart()->get();
|
|
||||||
|
|
||||||
// Get completed purchases
|
// Get completed purchases
|
||||||
$completed = ProductPurchase::completed()->get();
|
$completed = ProductPurchase::where('status', PurchaseStatus::COMPLETED)->get();
|
||||||
|
|
||||||
// Get purchases from specific cart
|
// Get pending purchases
|
||||||
$cartPurchases = ProductPurchase::fromCart($cartId)->get();
|
$pending = ProductPurchase::where('status', PurchaseStatus::PENDING)->get();
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stock Reservations
|
## Stock Claims
|
||||||
|
|
||||||
When adding products to cart, stock is automatically reserved:
|
When adding booking products to cart, stock is claimed at checkout time:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Stock is reserved when adding to cart
|
// For booking products, stock is NOT claimed when adding to cart
|
||||||
$cartItem = $user->addToCart($product, quantity: 2);
|
$cartItem = $user->addToCart($bookingProduct, quantity: 1, parameters: [
|
||||||
|
'from' => Carbon::parse('2025-01-15'),
|
||||||
|
'until' => Carbon::parse('2025-01-20'),
|
||||||
|
]);
|
||||||
|
|
||||||
// Reservation is created automatically
|
// Stock is validated and claimed during checkout
|
||||||
// It expires after configured time (default: 15 minutes)
|
$cart = $user->currentCart();
|
||||||
// Stock is released back when:
|
$cart->checkout(); // Claims stock at this point
|
||||||
// - Reservation expires
|
|
||||||
// - Cart item is removed
|
// For regular products, stock is claimed immediately when adding to cart
|
||||||
// - Cart is abandoned
|
$cartItem = $user->addToCart($regularProduct, quantity: 2);
|
||||||
|
// Stock is claimed immediately for non-booking products
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
@ -491,10 +695,14 @@ Route::post('/checkout', function () {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$purchases = $user->checkoutCart();
|
$cart = $user->currentCart();
|
||||||
|
$cart->checkout();
|
||||||
|
|
||||||
return redirect()->route('orders.success')
|
// Access the created order
|
||||||
->with('success', 'Order placed successfully!');
|
$order = $cart->order;
|
||||||
|
|
||||||
|
return redirect()->route('orders.success', ['order' => $order->id])
|
||||||
|
->with('success', "Order {$order->order_number} placed successfully!");
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return redirect()->back()->with('error', $e->getMessage());
|
return redirect()->back()->with('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
@ -504,11 +712,25 @@ Route::post('/checkout', function () {
|
||||||
Route::get('/orders', function () {
|
Route::get('/orders', function () {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
$purchases = $user->completedPurchases()
|
$orders = $user->orders()
|
||||||
->with('purchasable')
|
->with(['purchases.purchasable'])
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return view('orders.index', compact('purchases'));
|
return view('orders.index', compact('orders'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// View specific order
|
||||||
|
Route::get('/orders/{order}', function (Order $order) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
// Ensure user owns this order
|
||||||
|
if ($order->customer_id !== $user->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->load(['purchases.purchasable', 'notes']);
|
||||||
|
|
||||||
|
return view('orders.show', compact('order'));
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,16 @@ Redirect the user to the `url` to complete payment.
|
||||||
GET /api/shop/stripe/success?session_id={SESSION_ID}&cart_id={CART_ID}
|
GET /api/shop/stripe/success?session_id={SESSION_ID}&cart_id={CART_ID}
|
||||||
```
|
```
|
||||||
|
|
||||||
When payment is successful:
|
When payment is successful (handled via webhook):
|
||||||
- Cart status is updated to `CONVERTED`
|
- Cart status is updated to `CONVERTED`
|
||||||
- Cart's `converted_at` is set
|
- Cart's `converted_at` is set
|
||||||
- ProductPurchases are updated with:
|
- Order is created from the cart (if not already exists)
|
||||||
|
- Payment is recorded on the order
|
||||||
|
- ProductPurchases are created with:
|
||||||
- `status` → `COMPLETED`
|
- `status` → `COMPLETED`
|
||||||
- `charge_id` → Stripe Payment Intent ID
|
- `charge_id` → Stripe Payment Intent ID
|
||||||
- `amount_paid` → Amount from Stripe (in dollars, converted from cents)
|
- `amount` and `amount_paid` → Amount from Stripe (in cents)
|
||||||
|
- Order status changes to `PROCESSING`
|
||||||
|
|
||||||
### Cancel URL
|
### Cancel URL
|
||||||
|
|
||||||
|
|
@ -101,13 +104,31 @@ POST /api/shop/stripe/webhook
|
||||||
|
|
||||||
The webhook handler processes the following Stripe events:
|
The webhook handler processes the following Stripe events:
|
||||||
|
|
||||||
- `checkout.session.completed` - Updates cart to converted, updates purchases
|
**Checkout Session Events:**
|
||||||
|
- `checkout.session.completed` - Converts cart, creates order if needed, records payment
|
||||||
- `checkout.session.async_payment_succeeded` - Same as completed
|
- `checkout.session.async_payment_succeeded` - Same as completed
|
||||||
- `checkout.session.async_payment_failed` - Logs failure
|
- `checkout.session.async_payment_failed` - Marks order as failed if exists
|
||||||
- `charge.succeeded` - Updates purchases with charge info
|
- `checkout.session.expired` - Adds note to order
|
||||||
- `charge.failed` - Marks purchases as `FAILED`
|
|
||||||
- `payment_intent.succeeded` - Updates purchases
|
**Charge Events:**
|
||||||
- `payment_intent.payment_failed` - Marks purchases as `FAILED`
|
- `charge.succeeded` - Updates purchases with charge info, records payment on order
|
||||||
|
- `charge.failed` - Marks purchases as `FAILED`, adds note to order
|
||||||
|
- `charge.refunded` - Records refund on order
|
||||||
|
- `charge.dispute.created` - Puts order on hold, adds dispute note
|
||||||
|
- `charge.dispute.closed` - Updates order based on dispute outcome
|
||||||
|
|
||||||
|
**Payment Intent Events:**
|
||||||
|
- `payment_intent.succeeded` - Records payment on order
|
||||||
|
- `payment_intent.payment_failed` - Adds failure note to order
|
||||||
|
- `payment_intent.canceled` - Adds cancellation note
|
||||||
|
|
||||||
|
**Refund Events:**
|
||||||
|
- `refund.created` - Records refund on order
|
||||||
|
- `refund.updated` - Updates refund information
|
||||||
|
|
||||||
|
**Invoice Events** (for subscriptions):
|
||||||
|
- `invoice.payment_succeeded` - Handles subscription payments
|
||||||
|
- `invoice.payment_failed` - Handles failed subscription payments
|
||||||
|
|
||||||
### Configuring Webhook in Stripe
|
### Configuring Webhook in Stripe
|
||||||
|
|
||||||
|
|
@ -148,14 +169,30 @@ Route::post('custom/stripe/webhook', [StripeWebhookController::class, 'handleWeb
|
||||||
->name('shop.stripe.webhook');
|
->name('shop.stripe.webhook');
|
||||||
```
|
```
|
||||||
|
|
||||||
## ProductPurchase Updates
|
## ProductPurchase and Order Updates
|
||||||
|
|
||||||
The webhook handler automatically updates ProductPurchase records with charge information if the columns exist:
|
The webhook handler automatically updates ProductPurchase records and creates/updates Order records:
|
||||||
|
|
||||||
|
### Purchase Updates
|
||||||
- `charge_id` - Stripe Payment Intent ID
|
- `charge_id` - Stripe Payment Intent ID
|
||||||
- `amount_paid` - Amount paid in dollars
|
- `amount` - Amount in cents
|
||||||
|
- `amount_paid` - Amount paid in cents
|
||||||
|
- `status` - Updated to COMPLETED, FAILED, or REFUNDED based on event
|
||||||
|
|
||||||
These fields are automatically populated from the fillable array on the ProductPurchase model.
|
### Order Creation and Updates
|
||||||
|
When a checkout session is completed:
|
||||||
|
1. Cart is marked as CONVERTED
|
||||||
|
2. Order is created from cart (if doesn't exist) via `Order::createFromCart($cart)`
|
||||||
|
3. Payment is recorded on order via `$order->recordPayment($amount, $reference, 'stripe', 'stripe')`
|
||||||
|
4. Order status is updated to PROCESSING when payment is successful
|
||||||
|
5. OrderNote records are created for payment events
|
||||||
|
|
||||||
|
These fields are automatically populated:
|
||||||
|
- `payment_reference` - Stripe Payment Intent ID
|
||||||
|
- `payment_method` - 'stripe'
|
||||||
|
- `payment_provider` - 'stripe'
|
||||||
|
- `amount_paid` - Amount paid in cents
|
||||||
|
- `paid_at` - Timestamp when payment was received
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -664,4 +664,3 @@ $products = Product::with([
|
||||||
|
|
||||||
- [Pool Products](./ProductTypes/02-pool-products.md) - POOL/SINGLE relations in detail
|
- [Pool Products](./ProductTypes/02-pool-products.md) - POOL/SINGLE relations in detail
|
||||||
- [Product Types](./ProductTypes/) - Understanding different product types
|
- [Product Types](./ProductTypes/) - Understanding different product types
|
||||||
- [Stock Management](./06-stock-management.md) - How stock works with relations
|
|
||||||
|
|
|
||||||
|
|
@ -316,4 +316,3 @@ $available = $product->getAvailableStock($date);
|
||||||
|
|
||||||
- [Pool Products](./02-pool-products.md) - Managing groups of booking products
|
- [Pool Products](./02-pool-products.md) - Managing groups of booking products
|
||||||
- [Product Relations](../05-product-relations.md) - How products relate to each other
|
- [Product Relations](../05-product-relations.md) - How products relate to each other
|
||||||
- [Stock Management](../06-stock-management.md) - Detailed stock system documentation
|
|
||||||
|
|
|
||||||
|
|
@ -359,33 +359,77 @@ if (!$parkingPool->validatePoolConfiguration()['valid']) {
|
||||||
|
|
||||||
## Cart Integration
|
## Cart Integration
|
||||||
|
|
||||||
### Adding Pool to Cart
|
### Cart Item Tracking
|
||||||
|
|
||||||
|
When a pool product is added to cart, the system tracks which specific single item is allocated:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$from = Carbon::parse('2025-01-15');
|
$from = Carbon::parse('2025-01-15');
|
||||||
$until = Carbon::parse('2025-01-17'); // 2 days
|
$until = Carbon::parse('2025-01-17');
|
||||||
|
|
||||||
$cartItem = $cart->addToCart($parkingPool, $quantity = 1, [], $from, $until);
|
$cartItem = $cart->addToCart($parkingPool, $quantity = 1, [], $from, $until);
|
||||||
|
|
||||||
// Cart item properties:
|
// Cart item properties:
|
||||||
// - purchasable: Pool Product
|
// - purchasable_id: Pool Product ID
|
||||||
|
// - purchasable_type: Product::class
|
||||||
|
// - product_id: Allocated Single Item ID (NEW!)
|
||||||
// - quantity: 1
|
// - quantity: 1
|
||||||
// - from: 2025-01-15
|
// - from: 2025-01-15
|
||||||
// - until: 2025-01-17
|
// - until: 2025-01-17
|
||||||
// - price: (unit_amount × 2 days)
|
// - price: (unit_amount × 2 days)
|
||||||
// - meta->claimed_single_items: [spot_id]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Viewing Claimed Items
|
### Product ID Column
|
||||||
|
|
||||||
|
The `product_id` column in cart_items table stores the specific single item allocated from the pool:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$meta = $cartItem->getMeta();
|
$cartItem->product_id; // ID of the allocated single item
|
||||||
$claimedItemIds = $meta->claimed_single_items ?? [];
|
$cartItem->purchasable_id; // ID of the pool product
|
||||||
|
$cartItem->purchasable; // The pool product itself
|
||||||
|
$cartItem->product; // The allocated single item
|
||||||
|
|
||||||
// Load the actual products
|
// Get the effective product (allocated single or purchasable)
|
||||||
$claimedItems = Product::whereIn('id', $claimedItemIds)->get();
|
$effectiveProduct = $cartItem->getEffectiveProduct();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Viewing Allocated Items
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Get the allocated single item
|
||||||
|
$allocatedSingle = Product::find($cartItem->product_id);
|
||||||
|
|
||||||
|
// Or use the relationship
|
||||||
|
$allocatedSingle = $cartItem->product;
|
||||||
|
|
||||||
|
// Pool product is still accessible
|
||||||
|
$poolProduct = $cartItem->purchasable;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Changes and Reallocation
|
||||||
|
|
||||||
|
When cart dates change, the system automatically reallocates pool items to optimize pricing:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Update cart dates
|
||||||
|
$cart->setDates($newFrom, $newUntil);
|
||||||
|
|
||||||
|
// Behind the scenes:
|
||||||
|
// 1. System calls reallocatePoolItems($newFrom, $newUntil)
|
||||||
|
// 2. For each pool item, finds available singles for new dates
|
||||||
|
// 3. Applies pricing strategy (LOWEST, HIGHEST, AVERAGE)
|
||||||
|
// 4. Reallocates to better-priced singles if available
|
||||||
|
// 5. Updates cart_item.product_id to new allocation
|
||||||
|
// 6. Recalculates prices based on new dates
|
||||||
|
```
|
||||||
|
|
||||||
|
The `reallocatePoolItems()` method:
|
||||||
|
- Checks availability of all single items for the new dates
|
||||||
|
- Applies the pool's pricing strategy
|
||||||
|
- Reassigns cart items to optimal single items
|
||||||
|
- Updates `product_id` column with new allocation
|
||||||
|
- Marks items as unavailable if no singles are available for the period
|
||||||
|
|
||||||
### Removing from Cart
|
### Removing from Cart
|
||||||
|
|
||||||
```php
|
```php
|
||||||
|
|
@ -393,8 +437,8 @@ $cartItem->delete();
|
||||||
```
|
```
|
||||||
|
|
||||||
**What happens:**
|
**What happens:**
|
||||||
1. System finds claimed single items from metadata
|
1. System finds allocated single item from `product_id` column
|
||||||
2. Releases claims on each single item
|
2. Releases claims on the single item
|
||||||
3. Stock becomes available again
|
3. Stock becomes available again
|
||||||
|
|
||||||
## Advanced Usage
|
## Advanced Usage
|
||||||
|
|
@ -642,6 +686,22 @@ $pool->attachSingleItems($itemIds);
|
||||||
// - Items → Pool (POOL)
|
// - Items → Pool (POOL)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Wrong Single Item Allocated
|
||||||
|
|
||||||
|
**Cause:** Pricing strategy or date-based availability issue
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```php
|
||||||
|
// Force reallocation by updating cart dates
|
||||||
|
$cart->setDates($from, $until, $overwrite = true);
|
||||||
|
|
||||||
|
// Or manually check which single was allocated
|
||||||
|
$allocatedSingle = $cartItem->product;
|
||||||
|
|
||||||
|
// Verify pricing strategy is correct
|
||||||
|
$strategy = $pool->getPricingStrategy();
|
||||||
|
```
|
||||||
|
|
||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
### 1. Lazy Loading
|
### 1. Lazy Loading
|
||||||
|
|
@ -685,5 +745,3 @@ $pools->each(function($pool) {
|
||||||
|
|
||||||
- [Booking Products](./01-booking-products.md) - Understanding single items in pools
|
- [Booking Products](./01-booking-products.md) - Understanding single items in pools
|
||||||
- [Product Relations](../05-product-relations.md) - Relation system details
|
- [Product Relations](../05-product-relations.md) - Relation system details
|
||||||
- [Pricing Strategies](../07-pricing-strategies.md) - In-depth pricing documentation
|
|
||||||
- [Stock Management](../06-stock-management.md) - How stock system works
|
|
||||||
|
|
|
||||||
|
|
@ -584,8 +584,11 @@ class ShopAddExampleProducts extends Command
|
||||||
$prices = $productData['variation_prices'] ?? [];
|
$prices = $productData['variation_prices'] ?? [];
|
||||||
|
|
||||||
foreach ($variations as $index => $variation) {
|
foreach ($variations as $index => $variation) {
|
||||||
|
$variationName = ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation;
|
||||||
|
|
||||||
$variationProduct = Product::create([
|
$variationProduct = Product::create([
|
||||||
'slug' => $product->slug . '-' . \Illuminate\Support\Str::slug($variation),
|
'slug' => $product->slug . '-' . \Illuminate\Support\Str::slug($variation),
|
||||||
|
'name' => $variationName,
|
||||||
'sku' => $product->sku . '-' . strtoupper(substr($variation, 0, 3)),
|
'sku' => $product->sku . '-' . strtoupper(substr($variation, 0, 3)),
|
||||||
'type' => 'simple',
|
'type' => 'simple',
|
||||||
'parent_id' => $product->id,
|
'parent_id' => $product->id,
|
||||||
|
|
@ -596,7 +599,7 @@ class ShopAddExampleProducts extends Command
|
||||||
'meta' => ['variation' => $variation, 'example' => true],
|
'meta' => ['variation' => $variation, 'example' => true],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$variationProduct->setLocalized('name', ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation, null, true);
|
$variationProduct->setLocalized('name', $variationName, null, true);
|
||||||
|
|
||||||
$variationAmount = $prices[$index] ?? ($basePrice + ($index * 500));
|
$variationAmount = $prices[$index] ?? ($basePrice + ($index * 500));
|
||||||
$variationProduct->prices()->create([
|
$variationProduct->prices()->create([
|
||||||
|
|
@ -627,8 +630,11 @@ class ShopAddExampleProducts extends Command
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($productData['grouped_items'] as $i => $item) {
|
foreach ($productData['grouped_items'] as $i => $item) {
|
||||||
|
$itemName = $item['name'];
|
||||||
|
|
||||||
$childProduct = Product::create([
|
$childProduct = Product::create([
|
||||||
'slug' => $product->slug . '-item-' . ($i + 1),
|
'slug' => $product->slug . '-item-' . ($i + 1),
|
||||||
|
'name' => $itemName,
|
||||||
'sku' => $item['sku'],
|
'sku' => $item['sku'],
|
||||||
'type' => 'simple',
|
'type' => 'simple',
|
||||||
'parent_id' => $product->id,
|
'parent_id' => $product->id,
|
||||||
|
|
@ -639,7 +645,7 @@ class ShopAddExampleProducts extends Command
|
||||||
'meta' => ['grouped_item' => true, 'example' => true],
|
'meta' => ['grouped_item' => true, 'example' => true],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$childProduct->setLocalized('name', $item['name'], null, true);
|
$childProduct->setLocalized('name', $itemName, null, true);
|
||||||
|
|
||||||
$childProduct->prices()->create([
|
$childProduct->prices()->create([
|
||||||
'name' => 'Default',
|
'name' => 'Default',
|
||||||
|
|
@ -662,9 +668,11 @@ class ShopAddExampleProducts extends Command
|
||||||
|
|
||||||
$parkingIds = [];
|
$parkingIds = [];
|
||||||
foreach ($productData['pool_items'] as $i => $item) {
|
foreach ($productData['pool_items'] as $i => $item) {
|
||||||
|
$itemName = $item['name'];
|
||||||
|
|
||||||
$parking = Product::create([
|
$parking = Product::create([
|
||||||
'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($item['name']),
|
'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName),
|
||||||
'name' => $item['name'],
|
'name' => $itemName,
|
||||||
'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT),
|
'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT),
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
'status' => ProductStatus::PUBLISHED,
|
'status' => ProductStatus::PUBLISHED,
|
||||||
|
|
@ -675,20 +683,26 @@ class ShopAddExampleProducts extends Command
|
||||||
'meta' => ['example' => true, 'pool_item' => true, 'parent_pool' => $pool->name],
|
'meta' => ['example' => true, 'pool_item' => true, 'parent_pool' => $pool->name],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Set localized name for consistency with other products
|
||||||
|
$parking->setLocalized('name', $itemName, null, true);
|
||||||
|
|
||||||
// Set stock for the parking spot
|
// Set stock for the parking spot
|
||||||
$parking->increaseStock($item['stock']);
|
$parking->increaseStock($item['stock']);
|
||||||
|
|
||||||
// Create price for individual parking spot
|
// Create price for individual parking spot
|
||||||
$parking->prices()->create([
|
// Note: If price is not provided, the pool's fallback price will be used during checkout
|
||||||
'name' => 'Default',
|
if (!empty($item['price'])) {
|
||||||
'type' => 'one_time',
|
$parking->prices()->create([
|
||||||
'currency' => 'EUR',
|
'name' => 'Default',
|
||||||
'unit_amount' => $item['price'],
|
'type' => 'one_time',
|
||||||
'is_default' => true,
|
'currency' => 'EUR',
|
||||||
'active' => true,
|
'unit_amount' => $item['price'],
|
||||||
'billing_scheme' => 'per_unit',
|
'is_default' => true,
|
||||||
'meta' => ['example' => true],
|
'active' => true,
|
||||||
]);
|
'billing_scheme' => 'per_unit',
|
||||||
|
'meta' => ['example' => true],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$parkingIds[] = $parking->id;
|
$parkingIds[] = $parking->id;
|
||||||
}
|
}
|
||||||
|
|
@ -703,10 +717,10 @@ class ShopAddExampleProducts extends Command
|
||||||
|
|
||||||
// Get rooms
|
// Get rooms
|
||||||
$rooms = $this->createdProducts[ProductType::BOOKING->value] ?? [];
|
$rooms = $this->createdProducts[ProductType::BOOKING->value] ?? [];
|
||||||
|
|
||||||
// Get simple products (beverages)
|
// Get simple products (beverages)
|
||||||
$beverages = $this->createdProducts[ProductType::SIMPLE->value] ?? [];
|
$beverages = $this->createdProducts[ProductType::SIMPLE->value] ?? [];
|
||||||
|
|
||||||
// Get parking pools
|
// Get parking pools
|
||||||
$parkingPools = $this->createdProducts[ProductType::POOL->value] ?? [];
|
$parkingPools = $this->createdProducts[ProductType::POOL->value] ?? [];
|
||||||
|
|
||||||
|
|
@ -734,13 +748,13 @@ class ShopAddExampleProducts extends Command
|
||||||
$rooms[0]->productRelations()->syncWithoutDetaching([
|
$rooms[0]->productRelations()->syncWithoutDetaching([
|
||||||
$rooms[1]->id => ['type' => ProductRelationType::UPSELL->value]
|
$rooms[1]->id => ['type' => ProductRelationType::UPSELL->value]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (count($rooms) >= 3) {
|
if (count($rooms) >= 3) {
|
||||||
// Standard can also upsell to Presidential
|
// Standard can also upsell to Presidential
|
||||||
$rooms[0]->productRelations()->syncWithoutDetaching([
|
$rooms[0]->productRelations()->syncWithoutDetaching([
|
||||||
$rooms[2]->id => ['type' => ProductRelationType::UPSELL->value]
|
$rooms[2]->id => ['type' => ProductRelationType::UPSELL->value]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Deluxe can upsell to Presidential
|
// Deluxe can upsell to Presidential
|
||||||
$rooms[1]->productRelations()->syncWithoutDetaching([
|
$rooms[1]->productRelations()->syncWithoutDetaching([
|
||||||
$rooms[2]->id => ['type' => ProductRelationType::UPSELL->value]
|
$rooms[2]->id => ['type' => ProductRelationType::UPSELL->value]
|
||||||
|
|
|
||||||
|
|
@ -141,34 +141,45 @@ class StripeWebhookController
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record payment on the associated order
|
// Get or create order from the cart
|
||||||
$order = $cart->order;
|
$order = $cart->order;
|
||||||
if ($order) {
|
if (!$order) {
|
||||||
$amountPaid = (int) (($session->amount_total ?? 0) / 100);
|
// Create order from the converted cart
|
||||||
$currency = strtoupper($session->currency ?? $order->currency ?? 'USD');
|
$order = Order::createFromCart($cart);
|
||||||
|
|
||||||
// recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider)
|
Log::info('Order created from Stripe checkout session', [
|
||||||
$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_id' => $order->id,
|
||||||
'order_number' => $order->order_number,
|
'order_number' => $order->order_number,
|
||||||
'amount' => $amountPaid,
|
'cart_id' => $cart->id,
|
||||||
'currency' => $currency,
|
'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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -573,8 +573,7 @@ class Cart extends Model
|
||||||
|
|
||||||
// For pool products, check if allocated by reallocatePoolItems
|
// For pool products, check if allocated by reallocatePoolItems
|
||||||
if ($product instanceof Product && $product->isPool()) {
|
if ($product instanceof Product && $product->isPool()) {
|
||||||
$meta = $item->getMeta();
|
$allocatedSingleItemId = $item->product_id;
|
||||||
$allocatedSingleItemId = $meta->allocated_single_item_id ?? null;
|
|
||||||
|
|
||||||
// If this item was NOT allocated (no single assigned), skip updateDates
|
// If this item was NOT allocated (no single assigned), skip updateDates
|
||||||
// to preserve the null price set by reallocatePoolItems
|
// to preserve the null price set by reallocatePoolItems
|
||||||
|
|
@ -702,13 +701,13 @@ class Cart extends Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear allocation and set price to null to indicate unavailable
|
// Clear allocation and set price to null to indicate unavailable
|
||||||
$cartItem->updateMetaKey('allocated_single_item_id', null);
|
|
||||||
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
|
||||||
$cartItem->update([
|
$cartItem->update([
|
||||||
|
'product_id' => null,
|
||||||
'price' => null,
|
'price' => null,
|
||||||
'subtotal' => null,
|
'subtotal' => null,
|
||||||
'unit_amount' => null,
|
'unit_amount' => null,
|
||||||
]);
|
]);
|
||||||
|
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -747,12 +746,16 @@ class Cart extends Model
|
||||||
|
|
||||||
if ($remainingFromSingle >= $neededQty) {
|
if ($remainingFromSingle >= $neededQty) {
|
||||||
// This single can accommodate the cart item's full quantity
|
// This single can accommodate the cart item's full quantity
|
||||||
$cartItem->updateMetaKey('allocated_single_item_id', $single->id);
|
// Update product_id to track the allocated single item
|
||||||
|
$updates = ['product_id' => $single->id];
|
||||||
|
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
|
||||||
|
$updates['price_id'] = $singleInfo['price_id'];
|
||||||
|
}
|
||||||
|
$cartItem->update($updates);
|
||||||
$cartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
$cartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
||||||
|
|
||||||
// Update price_id if changed
|
// Legacy: update price_id if changed (now handled in the update above)
|
||||||
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
|
if (false) {
|
||||||
$cartItem->update(['price_id' => $singleInfo['price_id']]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track usage
|
// Track usage
|
||||||
|
|
@ -784,17 +787,17 @@ class Cart extends Model
|
||||||
// Update the original cart item with reduced quantity
|
// Update the original cart item with reduced quantity
|
||||||
// Also update subtotal to match the new quantity
|
// Also update subtotal to match the new quantity
|
||||||
$newSubtotal = $cartItem->price * $qtyToAllocate;
|
$newSubtotal = $cartItem->price * $qtyToAllocate;
|
||||||
$cartItem->update([
|
$updates = [
|
||||||
'quantity' => $qtyToAllocate,
|
'quantity' => $qtyToAllocate,
|
||||||
'subtotal' => $newSubtotal,
|
'subtotal' => $newSubtotal,
|
||||||
]);
|
'product_id' => $single->id,
|
||||||
$cartItem->refresh(); // Ensure model reflects database state
|
];
|
||||||
$cartItem->updateMetaKey('allocated_single_item_id', $single->id);
|
|
||||||
$cartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
|
||||||
|
|
||||||
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
|
if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) {
|
||||||
$cartItem->update(['price_id' => $singleInfo['price_id']]);
|
$updates['price_id'] = $singleInfo['price_id'];
|
||||||
}
|
}
|
||||||
|
$cartItem->update($updates);
|
||||||
|
$cartItem->refresh(); // Ensure model reflects database state
|
||||||
|
$cartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
||||||
|
|
||||||
$firstAllocation = false;
|
$firstAllocation = false;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -814,6 +817,7 @@ class Cart extends Model
|
||||||
$newCartItem = $this->items()->create([
|
$newCartItem = $this->items()->create([
|
||||||
'purchasable_id' => $cartItem->purchasable_id,
|
'purchasable_id' => $cartItem->purchasable_id,
|
||||||
'purchasable_type' => $cartItem->purchasable_type,
|
'purchasable_type' => $cartItem->purchasable_type,
|
||||||
|
'product_id' => $single->id,
|
||||||
'price_id' => $priceModel?->id,
|
'price_id' => $priceModel?->id,
|
||||||
'quantity' => $qtyToAllocate,
|
'quantity' => $qtyToAllocate,
|
||||||
'price' => $pricePerUnit,
|
'price' => $pricePerUnit,
|
||||||
|
|
@ -825,7 +829,6 @@ class Cart extends Model
|
||||||
'until' => $until,
|
'until' => $until,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$newCartItem->updateMetaKey('allocated_single_item_id', $single->id);
|
|
||||||
$newCartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
$newCartItem->updateMetaKey('allocated_single_item_name', $single->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -838,13 +841,13 @@ class Cart extends Model
|
||||||
if ($remainingQty > 0) {
|
if ($remainingQty > 0) {
|
||||||
if ($firstAllocation) {
|
if ($firstAllocation) {
|
||||||
// Couldn't allocate anything - mark as unavailable
|
// Couldn't allocate anything - mark as unavailable
|
||||||
$cartItem->updateMetaKey('allocated_single_item_id', null);
|
|
||||||
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
|
||||||
$cartItem->update([
|
$cartItem->update([
|
||||||
|
'product_id' => null,
|
||||||
'price' => null,
|
'price' => null,
|
||||||
'subtotal' => null,
|
'subtotal' => null,
|
||||||
'unit_amount' => null,
|
'unit_amount' => null,
|
||||||
]);
|
]);
|
||||||
|
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
||||||
} else {
|
} else {
|
||||||
// Partial allocation - the cart item was already updated with what we could allocate
|
// Partial allocation - the cart item was already updated with what we could allocate
|
||||||
// The remaining quantity is lost (over-capacity)
|
// The remaining quantity is lost (over-capacity)
|
||||||
|
|
@ -1219,9 +1222,8 @@ class Cart extends Model
|
||||||
$expectedPrice = $poolItemData['price'] ?? null;
|
$expectedPrice = $poolItemData['price'] ?? null;
|
||||||
$expectedSingleItemId = $poolItemData['item']?->id ?? null;
|
$expectedSingleItemId = $poolItemData['item']?->id ?? null;
|
||||||
|
|
||||||
// Get the allocated single item ID from the existing cart item's meta
|
// Get the allocated single item ID from the cart item's product_id column
|
||||||
$existingMeta = $item->getMeta();
|
$existingAllocatedItemId = $item->product_id;
|
||||||
$existingAllocatedItemId = $existingMeta->allocated_single_item_id ?? null;
|
|
||||||
|
|
||||||
// Only merge if:
|
// Only merge if:
|
||||||
// 1. price_id matches (same price source)
|
// 1. price_id matches (same price source)
|
||||||
|
|
@ -1272,11 +1274,7 @@ class Cart extends Model
|
||||||
$inCart = $this->items()
|
$inCart = $this->items()
|
||||||
->where('purchasable_id', $cartable->getKey())
|
->where('purchasable_id', $cartable->getKey())
|
||||||
->where('purchasable_type', get_class($cartable))
|
->where('purchasable_type', get_class($cartable))
|
||||||
->get()
|
->where('product_id', $single->id)
|
||||||
->filter(function ($item) use ($single) {
|
|
||||||
$meta = $item->getMeta();
|
|
||||||
return isset($meta->allocated_single_item_id) && $meta->allocated_single_item_id == $single->id;
|
|
||||||
})
|
|
||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
|
|
||||||
if ($available === PHP_INT_MAX || $inCart < $available) {
|
if ($available === PHP_INT_MAX || $inCart < $available) {
|
||||||
|
|
@ -1360,6 +1358,7 @@ class Cart extends Model
|
||||||
$cartItem = $this->items()->create([
|
$cartItem = $this->items()->create([
|
||||||
'purchasable_id' => $cartable->getKey(),
|
'purchasable_id' => $cartable->getKey(),
|
||||||
'purchasable_type' => get_class($cartable),
|
'purchasable_type' => get_class($cartable),
|
||||||
|
'product_id' => ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) ? $poolSingleItem->id : null,
|
||||||
'price_id' => $priceId,
|
'price_id' => $priceId,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'price' => $pricePerUnit, // Price per unit for the period
|
'price' => $pricePerUnit, // Price per unit for the period
|
||||||
|
|
@ -1371,9 +1370,8 @@ class Cart extends Model
|
||||||
'until' => ($is_booking) ? $until : null,
|
'until' => ($is_booking) ? $until : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// For pool products, store which single item is being used in meta
|
// For pool products, store the single item name in meta for display purposes
|
||||||
if ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) {
|
if ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) {
|
||||||
$cartItem->updateMetaKey('allocated_single_item_id', $poolSingleItem->id);
|
|
||||||
$cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name);
|
$cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1808,7 +1806,7 @@ class Cart extends Model
|
||||||
* d) If the product is a pool:
|
* d) If the product is a pool:
|
||||||
* - If the pool contains booking single items, a timespan is required.
|
* - If the pool contains booking single items, a timespan is required.
|
||||||
* - When a timespan exists and booking singles are used, claim stock:
|
* - When a timespan exists and booking singles are used, claim stock:
|
||||||
* - Use a pre-allocated single item from item meta (`allocated_single_item_id`) when present.
|
* - Use a pre-allocated single item from the `product_id` column when present.
|
||||||
* - Otherwise call the pool stock claiming logic (`claimPoolStock`).
|
* - Otherwise call the pool stock claiming logic (`claimPoolStock`).
|
||||||
* - Persist claimed single-item IDs into cart item meta (`claimed_single_items`).
|
* - Persist claimed single-item IDs into cart item meta (`claimed_single_items`).
|
||||||
* e) If the product is a non-pool booking product, require a timespan.
|
* e) If the product is a non-pool booking product, require a timespan.
|
||||||
|
|
@ -1885,13 +1883,12 @@ class Cart extends Model
|
||||||
// If pool has timespan and has booking single items, claim stock from single items
|
// If pool has timespan and has booking single items, claim stock from single items
|
||||||
if ($from && $until && $product->hasBookingSingleItems()) {
|
if ($from && $until && $product->hasBookingSingleItems()) {
|
||||||
try {
|
try {
|
||||||
// Check if we have pre-allocated single items from reallocation
|
// Check if we have pre-allocated single items from product_id column
|
||||||
$meta = $item->getMeta();
|
$allocatedSingleId = $item->product_id;
|
||||||
$allocatedSingleId = $meta->allocated_single_item_id ?? null;
|
|
||||||
|
|
||||||
if ($allocatedSingleId) {
|
if ($allocatedSingleId) {
|
||||||
// Use the pre-allocated single item
|
// Use the pre-allocated single item from product_id
|
||||||
$singleItem = Product::find($allocatedSingleId);
|
$singleItem = $item->product;
|
||||||
if (!$singleItem) {
|
if (!$singleItem) {
|
||||||
throw new \Exception("Allocated single item not found: {$allocatedSingleId}");
|
throw new \Exception("Allocated single item not found: {$allocatedSingleId}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class CartItem extends Model
|
||||||
'cart_id',
|
'cart_id',
|
||||||
'purchasable_id',
|
'purchasable_id',
|
||||||
'purchasable_type',
|
'purchasable_type',
|
||||||
|
'product_id',
|
||||||
'price_id',
|
'price_id',
|
||||||
'quantity',
|
'quantity',
|
||||||
'price',
|
'price',
|
||||||
|
|
@ -96,10 +97,28 @@ class CartItem extends Model
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function product(): BelongsTo|null
|
/**
|
||||||
|
* Get the actual product being purchased.
|
||||||
|
* For pool products, this is the single item allocated.
|
||||||
|
* For regular products, this returns the purchasable product itself.
|
||||||
|
*/
|
||||||
|
public function product(): BelongsTo
|
||||||
{
|
{
|
||||||
if ($this->purchasable_type === config('shop.models.product', Product::class)) {
|
return $this->belongsTo(config('shop.models.product', Product::class), 'product_id');
|
||||||
return $this->belongsTo(config('shop.models.product'), 'purchasable_id');
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective product - either the allocated product_id or the purchasable.
|
||||||
|
* This is useful for getting the actual product when product_id may be null.
|
||||||
|
*/
|
||||||
|
public function getEffectiveProduct(): ?Product
|
||||||
|
{
|
||||||
|
if ($this->product_id) {
|
||||||
|
return $this->product;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->purchasable instanceof Product) {
|
||||||
|
return $this->purchasable;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -482,12 +501,12 @@ class CartItem extends Model
|
||||||
|
|
||||||
// For pool products with an allocated single, use the allocated single's price
|
// For pool products with an allocated single, use the allocated single's price
|
||||||
// This ensures consistency when reallocatePoolItems has already assigned a specific single
|
// This ensures consistency when reallocatePoolItems has already assigned a specific single
|
||||||
$meta = $this->getMeta();
|
// The product_id column stores the actual single product being purchased
|
||||||
$allocatedSingleItemId = $meta->allocated_single_item_id ?? null;
|
$allocatedSingleItemId = $this->product_id;
|
||||||
|
|
||||||
if ($product->isPool() && $allocatedSingleItemId) {
|
if ($product->isPool() && $allocatedSingleItemId) {
|
||||||
// Get the allocated single item
|
// Get the allocated single item from the product_id column
|
||||||
$allocatedSingle = Product::find($allocatedSingleItemId);
|
$allocatedSingle = $this->product;
|
||||||
|
|
||||||
if ($allocatedSingle) {
|
if ($allocatedSingle) {
|
||||||
// Get price from the allocated single, with fallback to pool price
|
// Get price from the allocated single, with fallback to pool price
|
||||||
|
|
|
||||||
|
|
@ -786,7 +786,7 @@ trait MayBePoolProduct
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build usage map: track which single items have been allocated
|
// Build usage map: track which single items have been allocated
|
||||||
// Use allocated_single_item_id from meta to track actual single item usage
|
// Use product_id column to track actual single item allocation
|
||||||
// ONLY count items that overlap with the current booking period
|
// ONLY count items that overlap with the current booking period
|
||||||
// Exclude the specified cart item (if updating dates on existing item)
|
// Exclude the specified cart item (if updating dates on existing item)
|
||||||
$singleItemUsage = []; // item_id => quantity used
|
$singleItemUsage = []; // item_id => quantity used
|
||||||
|
|
@ -819,8 +819,8 @@ trait MayBePoolProduct
|
||||||
}
|
}
|
||||||
// else: no dates provided, count all items for progressive pricing
|
// else: no dates provided, count all items for progressive pricing
|
||||||
|
|
||||||
$meta = $item->getMeta();
|
// Get the allocated single item ID from the product_id column
|
||||||
$allocatedItemId = $meta->allocated_single_item_id ?? null;
|
$allocatedItemId = $item->product_id;
|
||||||
|
|
||||||
if ($allocatedItemId) {
|
if ($allocatedItemId) {
|
||||||
$singleItemUsage[$allocatedItemId] = ($singleItemUsage[$allocatedItemId] ?? 0) + $item->quantity;
|
$singleItemUsage[$allocatedItemId] = ($singleItemUsage[$allocatedItemId] ?? 0) + $item->quantity;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
$this->cart->addToCart($pool, 3, [], $from, $until);
|
||||||
|
|
||||||
// Manually simulate an item becoming unavailable:
|
// Manually simulate an item becoming unavailable:
|
||||||
// - Remove allocation
|
// - Remove allocation (product_id = null)
|
||||||
// - Set price to null (the real indicator of unavailability)
|
// - Set price to null (the real indicator of unavailability)
|
||||||
$item = $this->cart->items()->first();
|
$item = $this->cart->items()->first();
|
||||||
$meta = $item->getMeta();
|
$meta = $item->getMeta();
|
||||||
unset($meta->allocated_single_item_id);
|
|
||||||
unset($meta->allocated_single_item_name);
|
unset($meta->allocated_single_item_name);
|
||||||
$item->update([
|
$item->update([
|
||||||
|
'product_id' => null,
|
||||||
'meta' => json_encode($meta),
|
'meta' => json_encode($meta),
|
||||||
'price' => null,
|
'price' => null,
|
||||||
'subtotal' => null,
|
'subtotal' => null,
|
||||||
|
|
@ -245,8 +245,7 @@ class CartItemAvailabilityValidationTest extends TestCase
|
||||||
|
|
||||||
// Verify all items are allocated and ready
|
// Verify all items are allocated and ready
|
||||||
foreach ($this->cart->items as $item) {
|
foreach ($this->cart->items as $item) {
|
||||||
$meta = $item->getMeta();
|
$this->assertNotNull($item->product_id, 'Item should have product_id allocated');
|
||||||
$this->assertNotNull($meta->allocated_single_item_id ?? null, 'Item should be allocated');
|
|
||||||
$this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready');
|
$this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
#[Test]
|
||||||
public function it_stores_allocated_single_item_in_meta()
|
public function it_stores_allocated_single_item_in_product_id_column()
|
||||||
{
|
{
|
||||||
// Set pricing strategy to lowest
|
// Set pricing strategy to lowest
|
||||||
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
|
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
|
||||||
|
|
@ -136,28 +136,28 @@ class PoolProductPriceIdTest extends TestCase
|
||||||
// Add pool to cart
|
// Add pool to cart
|
||||||
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
|
$cartItem = $this->cart->addToCart($this->poolProduct, 1);
|
||||||
|
|
||||||
// Check meta contains allocated single item info
|
// Check product_id column contains allocated single item id
|
||||||
|
$this->assertNotNull($cartItem->product_id);
|
||||||
|
$this->assertEquals($this->singleItem1->id, $cartItem->product_id);
|
||||||
|
|
||||||
|
// Meta should still have the name for display purposes
|
||||||
$meta = $cartItem->getMeta();
|
$meta = $cartItem->getMeta();
|
||||||
$this->assertNotNull($meta->allocated_single_item_id ?? null);
|
|
||||||
$this->assertEquals($this->singleItem1->id, $meta->allocated_single_item_id);
|
|
||||||
$this->assertEquals($this->singleItem1->name, $meta->allocated_single_item_name);
|
$this->assertEquals($this->singleItem1->name, $meta->allocated_single_item_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function it_stores_different_single_items_in_meta_for_progressive_pricing()
|
public function it_stores_different_single_items_in_product_id_for_progressive_pricing()
|
||||||
{
|
{
|
||||||
// Set pricing strategy to lowest
|
// Set pricing strategy to lowest
|
||||||
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
|
$this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST);
|
||||||
|
|
||||||
// Add first pool item
|
// Add first pool item
|
||||||
$cartItem1 = $this->cart->addToCart($this->poolProduct, 1);
|
$cartItem1 = $this->cart->addToCart($this->poolProduct, 1);
|
||||||
$meta1 = $cartItem1->getMeta();
|
$this->assertEquals($this->singleItem1->id, $cartItem1->product_id);
|
||||||
$this->assertEquals($this->singleItem1->id, $meta1->allocated_single_item_id);
|
|
||||||
|
|
||||||
// Add second pool item
|
// Add second pool item
|
||||||
$cartItem2 = $this->cart->addToCart($this->poolProduct, 1);
|
$cartItem2 = $this->cart->addToCart($this->poolProduct, 1);
|
||||||
$meta2 = $cartItem2->getMeta();
|
$this->assertEquals($this->singleItem2->id, $cartItem2->product_id);
|
||||||
$this->assertEquals($this->singleItem2->id, $meta2->allocated_single_item_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|
@ -183,13 +183,12 @@ class PoolProductPriceIdTest extends TestCase
|
||||||
$this->assertEquals($poolPrice->id, $cartItem->price_id);
|
$this->assertEquals($poolPrice->id, $cartItem->price_id);
|
||||||
$this->assertEquals(3000, $cartItem->price);
|
$this->assertEquals(3000, $cartItem->price);
|
||||||
|
|
||||||
// Meta should indicate which single item was allocated
|
// product_id should indicate which single item was allocated
|
||||||
// Even though the pool's price is used as fallback, one of the single items is still allocated
|
// Even though the pool's price is used as fallback, one of the single items is still allocated
|
||||||
$meta = $cartItem->getMeta();
|
$this->assertNotNull($cartItem->product_id);
|
||||||
$this->assertNotNull($meta->allocated_single_item_id ?? null);
|
|
||||||
$this->assertTrue(
|
$this->assertTrue(
|
||||||
$meta->allocated_single_item_id === $this->singleItem1->id ||
|
$cartItem->product_id === $this->singleItem1->id ||
|
||||||
$meta->allocated_single_item_id === $this->singleItem2->id,
|
$cartItem->product_id === $this->singleItem2->id,
|
||||||
'Allocated single item should be one of the pool\'s single items'
|
'Allocated single item should be one of the pool\'s single items'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -838,7 +838,7 @@ class PoolProductionBugTest extends TestCase
|
||||||
'id' => $item->id,
|
'id' => $item->id,
|
||||||
'quantity' => $item->quantity,
|
'quantity' => $item->quantity,
|
||||||
'price' => $item->price,
|
'price' => $item->price,
|
||||||
'allocated_id' => $meta->allocated_single_item_id ?? null,
|
'allocated_id' => $item->product_id,
|
||||||
'allocated_name' => $meta->allocated_single_item_name ?? 'none',
|
'allocated_name' => $meta->allocated_single_item_name ?? 'none',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -855,7 +855,7 @@ class PoolProductionBugTest extends TestCase
|
||||||
'id' => $item->id,
|
'id' => $item->id,
|
||||||
'quantity' => $item->quantity,
|
'quantity' => $item->quantity,
|
||||||
'price' => $item->price,
|
'price' => $item->price,
|
||||||
'allocated_id' => $meta->allocated_single_item_id ?? null,
|
'allocated_id' => $item->product_id,
|
||||||
'allocated_name' => $meta->allocated_single_item_name ?? 'none',
|
'allocated_name' => $meta->allocated_single_item_name ?? 'none',
|
||||||
];
|
];
|
||||||
$totalQuantity += $item->quantity;
|
$totalQuantity += $item->quantity;
|
||||||
|
|
@ -870,7 +870,7 @@ class PoolProductionBugTest extends TestCase
|
||||||
|
|
||||||
// The issue: when items are merged, the allocation tracking might not work correctly
|
// The issue: when items are merged, the allocation tracking might not work correctly
|
||||||
// Each distinct single item should NOT be merged with others
|
// Each distinct single item should NOT be merged with others
|
||||||
// Items from the SAME single CAN be merged (they have same price and same allocated_single_item_id)
|
// Items from the SAME single CAN be merged (they have same price and same product_id)
|
||||||
|
|
||||||
// Check that we have correct allocations:
|
// Check that we have correct allocations:
|
||||||
// - 3 quantity allocated to single_1
|
// - 3 quantity allocated to single_1
|
||||||
|
|
@ -879,14 +879,13 @@ class PoolProductionBugTest extends TestCase
|
||||||
$single2Quantity = 0;
|
$single2Quantity = 0;
|
||||||
$single3Quantity = 0;
|
$single3Quantity = 0;
|
||||||
|
|
||||||
// Verify EACH cart item has allocated_single_item_id set
|
// Verify EACH cart item has product_id set
|
||||||
foreach ($cartItems as $item) {
|
foreach ($cartItems as $item) {
|
||||||
$meta = $item->getMeta();
|
$allocatedId = $item->product_id;
|
||||||
$allocatedId = $meta->allocated_single_item_id ?? null;
|
|
||||||
$this->assertNotNull(
|
$this->assertNotNull(
|
||||||
$allocatedId,
|
$allocatedId,
|
||||||
'Cart item id=' . $item->id . ' (qty=' . $item->quantity . ', price=' . $item->price .
|
'Cart item id=' . $item->id . ' (qty=' . $item->quantity . ', price=' . $item->price .
|
||||||
') should have allocated_single_item_id but has: ' . json_encode($meta)
|
') should have product_id but has: null'
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($allocatedId == $single_1->id) {
|
if ($allocatedId == $single_1->id) {
|
||||||
|
|
@ -958,8 +957,7 @@ class PoolProductionBugTest extends TestCase
|
||||||
->get();
|
->get();
|
||||||
$usageMap = [];
|
$usageMap = [];
|
||||||
foreach ($cartItemsAsSeenByPool as $ci) {
|
foreach ($cartItemsAsSeenByPool as $ci) {
|
||||||
$meta = $ci->getMeta();
|
$allocatedId = $ci->product_id;
|
||||||
$allocatedId = $meta->allocated_single_item_id ?? null;
|
|
||||||
if ($allocatedId) {
|
if ($allocatedId) {
|
||||||
$usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity;
|
$usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity;
|
||||||
}
|
}
|
||||||
|
|
@ -1002,14 +1000,13 @@ class PoolProductionBugTest extends TestCase
|
||||||
);
|
);
|
||||||
|
|
||||||
$item5 = $freshCart->addToCart($freshPool, 1);
|
$item5 = $freshCart->addToCart($freshPool, 1);
|
||||||
$meta5 = $item5->getMeta();
|
|
||||||
|
|
||||||
// Verify item 5 is allocated to single_3 (the only one with remaining capacity)
|
// Verify item 5 is allocated to single_3 (the only one with remaining capacity)
|
||||||
$this->assertEquals(
|
$this->assertEquals(
|
||||||
$single_3->id,
|
$single_3->id,
|
||||||
$meta5->allocated_single_item_id,
|
$item5->product_id,
|
||||||
'Item 5 should be allocated to single_3 (id=' . $single_3->id . ') since single_1 and single_2 are exhausted. ' .
|
'Item 5 should be allocated to single_3 (id=' . $single_3->id . ') since single_1 and single_2 are exhausted. ' .
|
||||||
'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null')
|
'Got product_id: ' . ($item5->product_id ?? 'null')
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if item 5 is actually a new item or a merged item
|
// Check if item 5 is actually a new item or a merged item
|
||||||
|
|
@ -1026,9 +1023,9 @@ class PoolProductionBugTest extends TestCase
|
||||||
|
|
||||||
$this->assertEquals(
|
$this->assertEquals(
|
||||||
$single_3->id,
|
$single_3->id,
|
||||||
$meta5->allocated_single_item_id,
|
$item5->product_id,
|
||||||
'Item 5 should be from single_3 (id=' . $single_3->id . ', name=' . $single_3->name . '). ' .
|
'Item 5 should be from single_3 (id=' . $single_3->id . ', name=' . $single_3->name . '). ' .
|
||||||
'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null') . '. ' .
|
'Got product_id: ' . ($item5->product_id ?? 'null') . '. ' .
|
||||||
'For reference: single_1=' . $single_1->id . ', single_2=' . $single_2->id
|
'For reference: single_1=' . $single_1->id . ', single_2=' . $single_2->id
|
||||||
);
|
);
|
||||||
$this->assertEquals(20004, $item5->price, 'Item 5 should cost 20004 (10002 * 2 days)');
|
$this->assertEquals(20004, $item5->price, 'Item 5 should cost 20004 (10002 * 2 days)');
|
||||||
|
|
@ -1081,7 +1078,7 @@ class PoolProductionBugTest extends TestCase
|
||||||
|
|
||||||
// Add first item - should get single_1
|
// Add first item - should get single_1
|
||||||
$item1 = $cart->addToCart($pool, 1);
|
$item1 = $cart->addToCart($pool, 1);
|
||||||
$this->assertEquals($single_1->id, $item1->getMeta()->allocated_single_item_id);
|
$this->assertEquals($single_1->id, $item1->product_id);
|
||||||
|
|
||||||
// After 1 item, next should still be single_1 (has 2 stock)
|
// After 1 item, next should still be single_1 (has 2 stock)
|
||||||
$cart->refresh();
|
$cart->refresh();
|
||||||
|
|
@ -1090,7 +1087,7 @@ class PoolProductionBugTest extends TestCase
|
||||||
|
|
||||||
// Add second item - should get single_1 again
|
// Add second item - should get single_1 again
|
||||||
$item2 = $cart->addToCart($pool, 1);
|
$item2 = $cart->addToCart($pool, 1);
|
||||||
$this->assertEquals($single_1->id, $item2->getMeta()->allocated_single_item_id);
|
$this->assertEquals($single_1->id, $item2->product_id);
|
||||||
|
|
||||||
// After 2 items (both from single_1 which has stock=2), next should be single_2
|
// After 2 items (both from single_1 which has stock=2), next should be single_2
|
||||||
$cart->refresh();
|
$cart->refresh();
|
||||||
|
|
@ -1100,7 +1097,7 @@ class PoolProductionBugTest extends TestCase
|
||||||
|
|
||||||
// Add third item - should get single_2
|
// Add third item - should get single_2
|
||||||
$item3 = $cart->addToCart($pool, 1);
|
$item3 = $cart->addToCart($pool, 1);
|
||||||
$this->assertEquals($single_2->id, $item3->getMeta()->allocated_single_item_id);
|
$this->assertEquals($single_2->id, $item3->product_id);
|
||||||
|
|
||||||
// Total: 1000 + 1000 + 2000 = 4000
|
// Total: 1000 + 1000 + 2000 = 4000
|
||||||
$this->assertEquals(4000, $cart->fresh()->getTotal());
|
$this->assertEquals(4000, $cart->fresh()->getTotal());
|
||||||
|
|
|
||||||
|
|
@ -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->assertNotNull($failNote);
|
||||||
$this->assertStringContainsString('declined', $failNote->content);
|
$this->assertStringContainsString('declined', $failNote->content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// STRIPE CHECKOUT SESSION FLOW TESTS (No pre-existing order)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function checkout_session_completed_creates_order_when_none_exists()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$product = $this->createProduct(100.00);
|
||||||
|
|
||||||
|
// Add to cart but DON'T call checkoutCart() - simulate checkoutSession() flow
|
||||||
|
$customer->addToCart($product);
|
||||||
|
$cart = $customer->currentCart();
|
||||||
|
|
||||||
|
// Verify no order exists yet
|
||||||
|
$this->assertNull($cart->order);
|
||||||
|
|
||||||
|
// Simulate what checkoutSession() does: mark cart as converted
|
||||||
|
$cart->update([
|
||||||
|
'status' => CartStatus::CONVERTED,
|
||||||
|
'converted_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Now simulate checkout session completed webhook
|
||||||
|
$session = $this->createMockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'amount_total' => 10000, // 100.00
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
|
||||||
|
// Verify order was created
|
||||||
|
$cart->refresh();
|
||||||
|
$order = $cart->order;
|
||||||
|
$this->assertNotNull($order, 'Order should be created by webhook');
|
||||||
|
$this->assertEquals($cart->id, $order->cart_id);
|
||||||
|
$this->assertEquals($customer->id, $order->customer_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function checkout_session_completed_creates_order_and_records_payment()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$product = $this->createProduct(150.00);
|
||||||
|
|
||||||
|
// Add to cart but DON'T call checkoutCart()
|
||||||
|
$customer->addToCart($product);
|
||||||
|
$cart = $customer->currentCart();
|
||||||
|
|
||||||
|
// Simulate checkoutSession() conversion
|
||||||
|
$cart->update([
|
||||||
|
'status' => CartStatus::CONVERTED,
|
||||||
|
'converted_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = $this->createMockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'amount_total' => 15000, // 150.00
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
'payment_intent' => 'pi_stripe_checkout_test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
|
||||||
|
|
||||||
|
$cart->refresh();
|
||||||
|
$order = $cart->order;
|
||||||
|
|
||||||
|
$this->assertNotNull($order);
|
||||||
|
$this->assertEquals(150.00, $order->amount_paid);
|
||||||
|
$this->assertEquals(OrderStatus::PROCESSING, $order->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function checkout_session_completed_creates_order_with_correct_totals()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$product = $this->createProduct(75.50);
|
||||||
|
|
||||||
|
$customer->addToCart($product, 2); // 2 items = 151.00
|
||||||
|
$cart = $customer->currentCart();
|
||||||
|
|
||||||
|
$cart->update([
|
||||||
|
'status' => CartStatus::CONVERTED,
|
||||||
|
'converted_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = $this->createMockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'amount_total' => 15100, // 151.00
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
|
||||||
|
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
|
||||||
|
$this->assertNotNull($order);
|
||||||
|
// Order total should match cart total (in cents)
|
||||||
|
$this->assertEquals((int) $cart->getTotal() * 100, $order->amount_total);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function checkout_session_completed_adds_payment_note_when_creating_order()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$product = $this->createProduct(50.00);
|
||||||
|
|
||||||
|
$customer->addToCart($product);
|
||||||
|
$cart = $customer->currentCart();
|
||||||
|
|
||||||
|
$cart->update([
|
||||||
|
'status' => CartStatus::CONVERTED,
|
||||||
|
'converted_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = $this->createMockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'amount_total' => 5000,
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
'payment_intent' => 'pi_test_payment_note',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
|
||||||
|
|
||||||
|
$order = $cart->fresh()->order;
|
||||||
|
|
||||||
|
$this->assertNotNull($order);
|
||||||
|
|
||||||
|
$paymentNote = $order->notes()->where('type', OrderNote::TYPE_PAYMENT)->first();
|
||||||
|
$this->assertNotNull($paymentNote, 'Payment note should be created');
|
||||||
|
$this->assertStringContainsString('50', $paymentNote->content);
|
||||||
|
$this->assertStringContainsString('Stripe checkout', $paymentNote->content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function checkout_session_completed_does_not_duplicate_order()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$product = $this->createProduct(100.00);
|
||||||
|
|
||||||
|
// Use checkoutCart() which creates an order
|
||||||
|
$customer->addToCart($product);
|
||||||
|
$cart = $customer->checkoutCart();
|
||||||
|
|
||||||
|
$existingOrder = $cart->fresh()->order;
|
||||||
|
$this->assertNotNull($existingOrder);
|
||||||
|
$originalOrderId = $existingOrder->id;
|
||||||
|
|
||||||
|
// Now call webhook - should NOT create a duplicate order
|
||||||
|
$session = $this->createMockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'amount_total' => 10000,
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
|
||||||
|
|
||||||
|
$cart->refresh();
|
||||||
|
|
||||||
|
// Should still be the same order
|
||||||
|
$this->assertEquals($originalOrderId, $cart->order->id);
|
||||||
|
|
||||||
|
// Should only have one order for this cart
|
||||||
|
$orderCount = Order::where('cart_id', $cart->id)->count();
|
||||||
|
$this->assertEquals(1, $orderCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function checkout_session_completed_without_prior_conversion_creates_order()
|
||||||
|
{
|
||||||
|
$customer = User::factory()->create();
|
||||||
|
$product = $this->createProduct(200.00);
|
||||||
|
|
||||||
|
// Add to cart - cart is NOT converted yet (simulates edge case)
|
||||||
|
$customer->addToCart($product);
|
||||||
|
$cart = $customer->currentCart();
|
||||||
|
|
||||||
|
$this->assertEquals(CartStatus::ACTIVE, $cart->status);
|
||||||
|
$this->assertNull($cart->order);
|
||||||
|
|
||||||
|
// Webhook fires - should convert cart AND create order
|
||||||
|
$session = $this->createMockSession([
|
||||||
|
'metadata' => (object) ['cart_id' => $cart->id],
|
||||||
|
'amount_total' => 20000,
|
||||||
|
'payment_status' => 'paid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->invokeMethod('handleCheckoutSessionCompleted', [$session]);
|
||||||
|
|
||||||
|
$cart->refresh();
|
||||||
|
|
||||||
|
// Cart should be converted
|
||||||
|
$this->assertEquals(CartStatus::CONVERTED, $cart->status);
|
||||||
|
$this->assertNotNull($cart->converted_at);
|
||||||
|
|
||||||
|
// Order should exist
|
||||||
|
$this->assertNotNull($cart->order);
|
||||||
|
$this->assertEquals(200.00, $cart->order->amount_paid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue