From 136b7ade63b3e309f08969bd65b3c3a58c6af896 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 30 Dec 2025 09:29:43 +0100 Subject: [PATCH] A prompts, I docs/readme, BF orders, R tests locations --- .github/copilot-instructions.md | 25 + .github/kaizen.md | 11 + .github/models.md | 69 + .github/repository.md | 195 +++ .github/traits.md | 63 + README.md | 419 ++----- .../create_blax_shop_tables.php.stub | 1 + docs/01-products.md | 30 +- docs/03-purchasing.md | 396 ++++-- docs/04-stripe-checkout.md | 63 +- docs/05-product-relations.md | 1 - docs/ProductTypes/01-booking-products.md | 1 - docs/ProductTypes/02-pool-products.md | 84 +- .../Commands/ShopAddExampleProducts.php | 50 +- .../Controllers/StripeWebhookController.php | 53 +- src/Models/Cart.php | 65 +- src/Models/CartItem.php | 33 +- src/Traits/MayBePoolProduct.php | 6 +- .../{ => Booking}/BookingFeatureTest.php | 0 .../BookingPerMinutePricingTest.php | 0 .../BookingTimespanValidationTest.php | 0 .../CartAddToCartPoolPricingTest.php | 0 .../CartCalendarAvailabilityTest.php | 0 .../{ => Cart}/CartDateManagementTest.php | 0 .../{ => Cart}/CartDateStringParsingTest.php | 0 tests/Feature/{ => Cart}/CartFacadeTest.php | 0 .../{ => Cart}/CartItemAttributesTest.php | 0 .../CartItemAvailabilityValidationTest.php | 545 ++++++++ .../{ => Cart}/CartItemDateManagementTest.php | 0 .../CartItemRequiredAdjustmentsTest.php | 0 .../Feature/{ => Cart}/CartManagementTest.php | 0 .../{ => Cart}/CartServiceBookingTest.php | 0 tests/Feature/{ => Cart}/GuestCartTest.php | 0 .../CartItemAvailabilityValidationTest.php | 7 +- .../CartCheckoutSessionTest.php | 0 .../CheckoutStockValidationTest.php | 0 .../{ => Checkout}/OrderCheckoutFlowTest.php | 0 .../PaymentMethodFieldsTest.php | 0 .../{ => Checkout}/PaymentProviderTest.php | 0 .../{ => Checkout}/PurchaseFlowTest.php | 0 .../PoolAvailabilityMethodsTest.php | 0 .../{ => Pool}/PoolBookingDetectionTest.php | 0 .../{ => Pool}/PoolClaimingPriorityTest.php | 0 .../PoolMaxQuantityValidationTest.php | 0 .../{ => Pool}/PoolParkingCartPricingTest.php | 0 .../{ => Pool}/PoolPerMinutePricingTest.php | 0 .../{ => Pool}/PoolProductCheckoutTest.php | 0 tests/Feature/Pool/PoolProductPriceIdTest.php | 232 ++++ .../PoolProductPricingFlexibilityTest.php | 0 .../{ => Pool}/PoolProductPricingTest.php | 0 .../{ => Pool}/PoolProductRelationsTest.php | 0 .../{ => Pool}/PoolProductStockTest.php | 0 tests/Feature/{ => Pool}/PoolProductTest.php | 0 tests/Feature/Pool/PoolProductionBugTest.php | 1105 +++++++++++++++++ .../{ => Pool}/PoolSeparateCartItemsTest.php | 0 .../{ => Pool}/PoolSmartAllocationTest.php | 0 tests/Feature/PoolProductPriceIdTest.php | 27 +- tests/Feature/PoolProductionBugTest.php | 31 +- .../{ => Product}/ProductActionTest.php | 0 .../{ => Product}/ProductAttributeTest.php | 0 .../{ => Product}/ProductCategoryTest.php | 0 .../{ => Product}/ProductManagementTest.php | 0 .../{ => Product}/ProductPriceTest.php | 0 .../ProductPricingValidationTest.php | 0 .../{ => Product}/ProductPurchaseTest.php | 0 .../{ => Product}/ProductScopeTest.php | 0 .../{ => Product}/ProductStockTest.php | 0 .../{ => Product}/StockAttributesTest.php | 0 .../{ => Product}/StockManagementTest.php | 0 .../PoolPricingReallocationBugTest.php | 748 +++++++++++ .../{ => Stripe}/StripeChargeFlowTest.php | 0 tests/Unit/{ => Cart}/CartExpirationTest.php | 0 tests/Unit/{ => Cart}/CartItemTest.php | 0 tests/Unit/{ => Cart}/CartTest.php | 0 tests/Unit/{ => Order}/HasOrdersTraitTest.php | 0 tests/Unit/{ => Order}/OrderNoteTest.php | 0 tests/Unit/{ => Order}/OrderSummaryTest.php | 0 tests/Unit/{ => Order}/OrderTest.php | 0 .../{ => Product}/ProductDuplicateTest.php | 0 .../Unit/{ => Product}/ProductPricingTest.php | 0 .../{ => Product}/ProductRelationsTest.php | 0 .../{ => Stripe}/StripeWebhookOrderTest.php | 203 +++ 82 files changed, 3881 insertions(+), 582 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/kaizen.md create mode 100644 .github/models.md create mode 100644 .github/repository.md create mode 100644 .github/traits.md rename tests/Feature/{ => Booking}/BookingFeatureTest.php (100%) rename tests/Feature/{ => Booking}/BookingPerMinutePricingTest.php (100%) rename tests/Feature/{ => Booking}/BookingTimespanValidationTest.php (100%) rename tests/Feature/{ => Cart}/CartAddToCartPoolPricingTest.php (100%) rename tests/Feature/{ => Cart}/CartCalendarAvailabilityTest.php (100%) rename tests/Feature/{ => Cart}/CartDateManagementTest.php (100%) rename tests/Feature/{ => Cart}/CartDateStringParsingTest.php (100%) rename tests/Feature/{ => Cart}/CartFacadeTest.php (100%) rename tests/Feature/{ => Cart}/CartItemAttributesTest.php (100%) create mode 100644 tests/Feature/Cart/CartItemAvailabilityValidationTest.php rename tests/Feature/{ => Cart}/CartItemDateManagementTest.php (100%) rename tests/Feature/{ => Cart}/CartItemRequiredAdjustmentsTest.php (100%) rename tests/Feature/{ => Cart}/CartManagementTest.php (100%) rename tests/Feature/{ => Cart}/CartServiceBookingTest.php (100%) rename tests/Feature/{ => Cart}/GuestCartTest.php (100%) rename tests/Feature/{ => Checkout}/CartCheckoutSessionTest.php (100%) rename tests/Feature/{ => Checkout}/CheckoutStockValidationTest.php (100%) rename tests/Feature/{ => Checkout}/OrderCheckoutFlowTest.php (100%) rename tests/Feature/{ => Checkout}/PaymentMethodFieldsTest.php (100%) rename tests/Feature/{ => Checkout}/PaymentProviderTest.php (100%) rename tests/Feature/{ => Checkout}/PurchaseFlowTest.php (100%) rename tests/Feature/{ => Pool}/PoolAvailabilityMethodsTest.php (100%) rename tests/Feature/{ => Pool}/PoolBookingDetectionTest.php (100%) rename tests/Feature/{ => Pool}/PoolClaimingPriorityTest.php (100%) rename tests/Feature/{ => Pool}/PoolMaxQuantityValidationTest.php (100%) rename tests/Feature/{ => Pool}/PoolParkingCartPricingTest.php (100%) rename tests/Feature/{ => Pool}/PoolPerMinutePricingTest.php (100%) rename tests/Feature/{ => Pool}/PoolProductCheckoutTest.php (100%) create mode 100644 tests/Feature/Pool/PoolProductPriceIdTest.php rename tests/Feature/{ => Pool}/PoolProductPricingFlexibilityTest.php (100%) rename tests/Feature/{ => Pool}/PoolProductPricingTest.php (100%) rename tests/Feature/{ => Pool}/PoolProductRelationsTest.php (100%) rename tests/Feature/{ => Pool}/PoolProductStockTest.php (100%) rename tests/Feature/{ => Pool}/PoolProductTest.php (100%) create mode 100644 tests/Feature/Pool/PoolProductionBugTest.php rename tests/Feature/{ => Pool}/PoolSeparateCartItemsTest.php (100%) rename tests/Feature/{ => Pool}/PoolSmartAllocationTest.php (100%) rename tests/Feature/{ => Product}/ProductActionTest.php (100%) rename tests/Feature/{ => Product}/ProductAttributeTest.php (100%) rename tests/Feature/{ => Product}/ProductCategoryTest.php (100%) rename tests/Feature/{ => Product}/ProductManagementTest.php (100%) rename tests/Feature/{ => Product}/ProductPriceTest.php (100%) rename tests/Feature/{ => Product}/ProductPricingValidationTest.php (100%) rename tests/Feature/{ => Product}/ProductPurchaseTest.php (100%) rename tests/Feature/{ => Product}/ProductScopeTest.php (100%) rename tests/Feature/{ => Product}/ProductStockTest.php (100%) rename tests/Feature/{ => Product}/StockAttributesTest.php (100%) rename tests/Feature/{ => Product}/StockManagementTest.php (100%) create mode 100644 tests/Feature/ProductionBugs/PoolPricingReallocationBugTest.php rename tests/Feature/{ => Stripe}/StripeChargeFlowTest.php (100%) rename tests/Unit/{ => Cart}/CartExpirationTest.php (100%) rename tests/Unit/{ => Cart}/CartItemTest.php (100%) rename tests/Unit/{ => Cart}/CartTest.php (100%) rename tests/Unit/{ => Order}/HasOrdersTraitTest.php (100%) rename tests/Unit/{ => Order}/OrderNoteTest.php (100%) rename tests/Unit/{ => Order}/OrderSummaryTest.php (100%) rename tests/Unit/{ => Order}/OrderTest.php (100%) rename tests/Unit/{ => Product}/ProductDuplicateTest.php (100%) rename tests/Unit/{ => Product}/ProductPricingTest.php (100%) rename tests/Unit/{ => Product}/ProductRelationsTest.php (100%) rename tests/Unit/{ => Stripe}/StripeWebhookOrderTest.php (70%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..06d3b5b --- /dev/null +++ b/.github/copilot-instructions.md @@ -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. diff --git a/.github/kaizen.md b/.github/kaizen.md new file mode 100644 index 0000000..29f5c50 --- /dev/null +++ b/.github/kaizen.md @@ -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. \ No newline at end of file diff --git a/.github/models.md b/.github/models.md new file mode 100644 index 0000000..6f7b2d0 --- /dev/null +++ b/.github/models.md @@ -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). diff --git a/.github/repository.md b/.github/repository.md new file mode 100644 index 0000000..0be7b1a --- /dev/null +++ b/.github/repository.md @@ -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 + 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. diff --git a/.github/traits.md b/.github/traits.md new file mode 100644 index 0000000..8c381fc --- /dev/null +++ b/.github/traits.md @@ -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. diff --git a/README.md b/README.md index ee152f3..1ef0725 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A comprehensive headless e-commerce package for Laravel with stock management, S ## Features -- 🛍️ **Product Management** - Simple, variable, grouped, and external products +- 🛍️ **Product Management** - Simple, variable, grouped, external, booking, and pool products - 💰 **Multi-Currency Support** - Handle multiple currencies with ease - 📦 **Advanced Stock Management** - Stock reservations, low stock alerts, and backorders - 💳 **Stripe Integration** - Built-in Stripe product and price synchronization @@ -41,6 +41,14 @@ Run migrations: php artisan migrate ``` +## Configuration + +The main configuration file is located at `config/shop.php`. Here you can configure: +- Database table names +- Caching settings +- Stripe integration keys and settings +- Currency settings + ## Quick Start ### Setup Your User Model @@ -49,6 +57,7 @@ Add the `HasShoppingCapabilities` trait to any model that should be able to purc ```php use Blax\Shop\Traits\HasShoppingCapabilities; +use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable { @@ -60,17 +69,25 @@ class User extends Authenticatable ### Creating Your First Product +Use the provided Enums to ensure type safety and consistency. + ```php use Blax\Shop\Models\Product; +use Blax\Shop\Enums\ProductType; +use Blax\Shop\Enums\ProductStatus; +use Blax\Shop\Enums\StockType; $product = Product::create([ 'slug' => 'amazing-t-shirt', 'sku' => 'TSH-001', - 'type' => 'simple', + 'type' => ProductType::SIMPLE, 'manage_stock' => true, - 'status' => 'published', + 'status' => ProductStatus::PUBLISHED, + 'name' => 'Amazing T-Shirt', // Uses meta translation + 'description' => 'A comfortable cotton t-shirt', ]); +// Add Price $product->prices()->create([ 'currency' => 'USD', 'unit_amount' => 1999, // $19.99 @@ -78,368 +95,96 @@ $product->prices()->create([ 'is_default' => true, ]); +// Manage Stock $product->adjustStock(StockType::INCREASE, 100); // Add 100 items to stock -$product->adjustStock(StockType::DECREASE, 90); // Remove 100 items from stock +$product->adjustStock(StockType::DECREASE, 10); // Remove 10 items from stock + +// Reserve Stock (e.g., for a booking) $product->adjustStock( StockType::CLAIMED, - 10, + 1, from: now(), until: now()->addDay(), - note: 'Booked' -); // Claim/reserve 10 stocks - - -// Add translated name -$product->setLocalized('name', 'Amazing T-Shirt', 'en'); -$product->setLocalized('description', 'A comfortable cotton t-shirt', 'en'); + note: 'Reserved for Order #123' +); ``` -### Working with Cart (Authenticated Users) - -```php -use Blax\Shop\Facades\Cart; -use Blax\Shop\Models\Product; - -$product = Product::find($productId); -$user = auth()->user(); - -// Add to cart (via facade) -Cart::add($product, quantity: 2); - -// Or via user trait -$cartItem = $user->addToCart($product, quantity: 1); - -// Get cart totals -$total = Cart::total(); -$itemCount = Cart::itemCount(); - -// Check if cart is empty -if (Cart::isEmpty()) { - // Cart is empty -} - -// Remove from cart -Cart::remove($product); - -// Clear entire cart -Cart::clear(); - -// Checkout cart -$completedPurchases = Cart::checkout(); -``` - -### Working with Guest Carts +### Working with Cart ```php use Blax\Shop\Facades\Cart; -// Create or retrieve guest cart (uses session ID automatically) -$guestCart = Cart::guest(); +// Add item to cart +Cart::addToCart($product, 1); -// Or with specific session ID -$guestCart = Cart::guest('custom-session-id'); +// Add item with date range (for bookings) +Cart::addToCart($product, 1, [], now(), now()->addDay()); -// Add items to guest cart -$guestCart->addToCart($product, quantity: 1); - -// Get guest cart totals -$total = Cart::total($guestCart); -$itemCount = Cart::itemCount($guestCart); - -// Check if guest cart is empty -if (Cart::isEmpty($guestCart)) { - // Cart is empty -} - -// Clear guest cart -Cart::clear($guestCart); - -// Convert guest cart to user cart on login -$guestCart->convertToUserCart($user); +// Checkout +$cart = Cart::getCart(); +$cart->checkout(); // Creates purchases, claims stock, etc. ``` -### Purchasing Products Directly +## Advanced Usage + +### Pool Products + +Pool products are collections of single items (e.g., "Parking Spaces" containing "Spot A1", "Spot A2"). ```php use Blax\Shop\Models\Product; +use Blax\Shop\Enums\ProductType; -$product = Product::find($productId); -$user = auth()->user(); - -// Simple purchase -$purchase = $user->purchase($product, quantity: 1); - -// Purchase with options -$purchase = $user->purchase($product, quantity: 2, options: [ - 'price_id' => $priceId, - 'charge_id' => $paymentIntent->id, +// Create the Pool Parent +$pool = Product::create([ + 'type' => ProductType::POOL, + 'name' => 'Parking Spaces', + 'manage_stock' => true, // Pool manages availability ]); -// Check if user has purchased -if ($user->hasPurchased($product)) { - // Grant access -} +// Create Single Items +$spot1 = Product::create([ + 'type' => ProductType::BOOKING, + 'name' => 'Spot A1', +]); + +$spot2 = Product::create([ + 'type' => ProductType::BOOKING, + 'name' => 'Spot A2', +]); + +// Attach Singles to Pool +$pool->attachSingleItems([$spot1->id, $spot2->id]); ``` -### Using Shop Facade +### Booking Products + +Booking products are time-based and require `from` and `until` dates when adding to cart. ```php -use Blax\Shop\Facades\Shop; +use Blax\Shop\Models\Product; +use Blax\Shop\Enums\ProductType; -// Get all products -$products = Shop::products()->get(); +$room = Product::create([ + 'type' => ProductType::BOOKING, + 'name' => 'Conference Room', + 'manage_stock' => true, +]); -// Get published products only -$products = Shop::published()->get(); - -// Get products in stock -$products = Shop::inStock()->get(); - -// Get featured products -$featured = Shop::featured()->get(); - -// Search products -$results = Shop::search('t-shirt')->get(); - -// Check stock availability -if (Shop::checkStock($product, quantity: 5)) { - // Sufficient stock available -} - -// Get available stock -$available = Shop::getAvailableStock($product); - -// Check if product is on sale -if (Shop::isOnSale($product)) { - // Show sale badge -} - -// Get configuration -$currency = Shop::currency(); // USD -$config = Shop::config('cart.expire_after_days', 30); +// Check availability +$isAvailable = $room->availableOnDate(now(), now()->addHour()); ``` +## Testing + +To run the package tests: + +```bash +./vendor/bin/phpunit +``` + +The tests use an in-memory SQLite database and Orchestra Testbench. + ## Documentation -- [Product Management](docs/01-products.md) -- [Stripe Integration](docs/02-stripe.md) -- [Purchasing Products](docs/03-purchasing.md) -- [Subscriptions](docs/04-subscriptions.md) -- [Stock Management](docs/05-stock.md) -- [API Usage](docs/06-api.md) - -## Models - -The package includes the following models: - -- **Product** - Main product model with support for simple, variable, grouped, and external products -- **ProductPrice** - Multi-currency pricing with sale prices and subscription support -- **ProductCategory** - Hierarchical product categories -- **ProductStock** - Advanced stock management with reservations and logging -- **ProductAttribute** - Product attributes (size, color, material, etc.) -- **ProductPurchase** - Purchase records and history -- **ProductAction** - Custom actions triggered by product events -- **ProductActionRun** - Execution logs for product actions -- **Cart** - Shopping cart for authenticated users and guests -- **CartItem** - Individual items in a cart -- **PaymentMethod** - Saved payment methods -- **PaymentProviderIdentity** - Links users to payment providers (Stripe, etc.) - -## Traits - -Available traits for your models: - -- **HasShoppingCapabilities** - Complete shopping functionality (cart + purchases) -- **HasCart** - Cart management functionality only -- **HasPaymentMethods** - Payment method management -- **HasStripeAccount** - Stripe integration for users -- **HasPrices** - Price management (for Product model) -- **HasStocks** - Stock management (for Product model) -- **HasCategories** - Category relationships (for Product model) -- **HasProductRelations** - Related products, upsells, cross-sells -- **HasChargingOptions** - Payment processing capabilities - -## Facades - -The package provides two facades for cleaner API access: - -### Shop Facade - -```php -use Blax\Shop\Facades\Shop; - -Shop::products() // Get product query builder -Shop::product($id) // Find product by ID -Shop::categories() // Get categories query builder -Shop::inStock() // Get in-stock products -Shop::featured() // Get featured products -Shop::published() // Get published products -Shop::search($query) // Search products -Shop::checkStock($product, $qty) // Check stock availability -Shop::getAvailableStock($product) // Get available stock quantity -Shop::isOnSale($product) // Check if product is on sale -Shop::config($key, $default) // Get shop configuration -Shop::currency() // Get default currency -``` - -### Cart Facade - -```php -use Blax\Shop\Facades\Cart; - -Cart::current() // Get current user's cart -Cart::guest($sessionId) // Get/create guest cart -Cart::forUser($user) // Get cart for specific user -Cart::find($cartId) // Find cart by ID -Cart::add($product, $qty, $params) // Add item to cart -Cart::remove($product, $qty) // Remove item from cart -Cart::update($cartItem, $qty) // Update cart item quantity -Cart::clear($cart) // Clear cart items -Cart::checkout($cart) // Checkout cart -Cart::total($cart) // Get cart total -Cart::itemCount($cart) // Get item count -Cart::items($cart) // Get cart items -Cart::isEmpty($cart) // Check if cart is empty -Cart::isExpired($cart) // Check if cart is expired -Cart::isConverted($cart) // Check if cart was converted -Cart::unpaidAmount($cart) // Get unpaid amount -Cart::paidAmount($cart) // Get paid amount -``` - -## Configuration - -The `config/shop.php` file contains all configuration options: - -```php -return [ - // Table names (customizable for multi-tenancy) - 'tables' => [ - 'products' => 'products', - 'product_categories' => 'product_categories', - 'product_prices' => 'product_prices', - 'product_stocks' => 'product_stocks', - 'product_attributes' => 'product_attributes', - 'product_purchases' => 'product_purchases', - 'product_actions' => 'product_actions', - 'product_action_runs' => 'product_action_runs', - 'product_relations' => 'product_relations', - 'carts' => 'carts', - 'cart_items' => 'cart_items', - 'cart_discounts' => 'cart_discounts', - 'payment_methods' => 'payment_methods', - 'payment_provider_identities' => 'payment_provider_identities', - ], - - // Model classes (allow overriding) - 'models' => [ - 'product' => \Blax\Shop\Models\Product::class, - 'product_price' => \Blax\Shop\Models\ProductPrice::class, - 'product_category' => \Blax\Shop\Models\ProductCategory::class, - 'product_stock' => \Blax\Shop\Models\ProductStock::class, - 'product_attribute' => \Blax\Shop\Models\ProductAttribute::class, - 'product_purchase' => \Blax\Shop\Models\ProductPurchase::class, - 'cart' => \Blax\Shop\Models\Cart::class, - 'cart_item' => \Blax\Shop\Models\CartItem::class, - 'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class, - 'payment_method' => \Blax\Shop\Models\PaymentMethod::class, - ], - - // API Routes - 'routes' => [ - 'enabled' => true, - 'prefix' => 'api/shop', - 'middleware' => ['api'], - 'name_prefix' => 'shop.', - ], - - // Stock management - 'stock' => [ - 'track_inventory' => true, - 'allow_backorders' => false, - 'low_stock_threshold' => 5, - 'log_changes' => true, - 'auto_release_expired' => true, - ], - - // Product actions - 'actions' => [ - 'path' => app_path('Jobs/ProductAction'), - 'namespace' => 'App\\Jobs\\ProductAction', - 'auto_discover' => true, - ], - - // Stripe integration - 'stripe' => [ - 'enabled' => env('SHOP_STRIPE_ENABLED', false), - 'sync_prices' => true, - ], - - // Cache configuration - 'cache' => [ - 'enabled' => env('SHOP_CACHE_ENABLED', true), - 'ttl' => 3600, - 'prefix' => 'shop:', - ], - - // Cart configuration - 'cart' => [ - 'expire_after_days' => 30, - 'auto_cleanup' => true, - 'merge_on_login' => true, - ], - - // API response format - 'api' => [ - 'include_meta' => true, - 'wrap_response' => true, - 'response_key' => 'data', - ], -]; -``` - -## Commands - -### Add Example Products - -Create example products for testing and demonstration purposes: - -```bash -# Create 2 products of each type (default) -php artisan shop:add-example-products - -# Create 5 products of each type -php artisan shop:add-example-products --count=5 - -# Clean existing example products first -php artisan shop:add-example-products --clean -``` - -This command creates: -- ✅ All 4 product types (simple, variable, grouped, external) -- ✅ Product categories -- ✅ Product attributes (material, size, color, etc.) -- ✅ Multiple pricing options (multi-currency, subscriptions) -- ✅ Example product actions (email notifications, stats updates) -- ✅ Variations for variable products -- ✅ Child products for grouped products -- ✅ Realistic data using Faker - -### Reinstall Shop Tables - -```bash -# With confirmation -php artisan shop:reinstall - -# Force without confirmation -php artisan shop:reinstall --force -``` - -⚠️ **Warning:** This will delete all shop data! - -## License - -MIT License - -## Support - -For issues and questions, please use the [GitHub issue tracker](https://github.com/blax/laravel-shop/issues). +For more detailed documentation, please refer to the `docs/` directory in the repository. diff --git a/database/migrations/create_blax_shop_tables.php.stub b/database/migrations/create_blax_shop_tables.php.stub index 59be724..78ebdce 100644 --- a/database/migrations/create_blax_shop_tables.php.stub +++ b/database/migrations/create_blax_shop_tables.php.stub @@ -281,6 +281,7 @@ return new class extends Migration $table->uuid('id')->primary(); $table->uuid('cart_id'); $table->uuidMorphs('purchasable'); + $table->foreignUuid('product_id')->nullable()->constrained(config('shop.tables.products', 'products'))->nullOnDelete(); $table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete(); $table->foreignUuid('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete(); $table->integer('quantity')->default(1); diff --git a/docs/01-products.md b/docs/01-products.md index be27c14..a1c8283 100644 --- a/docs/01-products.md +++ b/docs/01-products.md @@ -214,24 +214,28 @@ if ($product->isLowStock()) { } ``` -### Stock Reservations +### Stock Claims (Reservations) ```php use Blax\Shop\Models\ProductStock; -// Reserve stock temporarily -$reservation = $product->reserveStock( +// Claim stock temporarily (for bookings) +$claim = $product->claimStock( quantity: 2, reference: $cart, - until: now()->addMinutes(15), + from: now(), + until: now()->addDays(3), note: 'Cart reservation' ); -// Release reservation -$reservation->update(['status' => 'completed']); +// Release claim +$product->releaseStock($cart); -// Get active reservations -$reservations = $product->reservations()->get(); +// Get active claims +$claims = $product->stocks() + ->where('type', 'claimed') + ->where('status', 'pending') + ->get(); ``` ### Stock History @@ -399,25 +403,27 @@ use Blax\Shop\Models\ProductAction; // Send email on purchase ProductAction::create([ 'product_id' => $product->id, - 'action_type' => 'SendWelcomeEmail', - 'event' => 'purchased', + 'class' => \App\Jobs\SendWelcomeEmail::class, + 'events' => ['purchased'], 'parameters' => [ 'template' => 'welcome', 'delay' => 0, ], 'active' => true, + 'defer' => true, 'sort_order' => 1, ]); // Grant access on purchase ProductAction::create([ 'product_id' => $product->id, - 'action_type' => 'GrantCourseAccess', - 'event' => 'purchased', + 'class' => \App\Jobs\GrantCourseAccess::class, + 'events' => ['purchased'], 'parameters' => [ 'course_id' => 123, ], 'active' => true, + 'defer' => true, 'sort_order' => 2, ]); ``` diff --git a/docs/03-purchasing.md b/docs/03-purchasing.md index e8ec3c1..d35e5a5 100644 --- a/docs/03-purchasing.md +++ b/docs/03-purchasing.md @@ -87,8 +87,17 @@ $user = auth()->user(); $product = Product::find($productId); try { + // For regular products $cartItem = $user->addToCart($product, quantity: 1); + // For booking products (requires dates) + $from = Carbon::parse('2025-01-15'); + $until = Carbon::parse('2025-01-20'); + $cartItem = $user->addToCart($product, quantity: 1, parameters: [ + 'from' => $from, + 'until' => $until, + ]); + return response()->json([ 'success' => true, 'cart_item' => $cartItem, @@ -187,20 +196,23 @@ $stats = [ ## Cart Checkout -### Convert Cart to Purchases +### Checkout Cart ```php try { - $purchases = $user->checkoutCart(); + // Get current cart + $cart = $user->currentCart(); - // Checkout successful - // Cart items are now converted to completed purchases - // Cart is marked as converted + // Checkout (creates purchases and order) + $cart->checkout(); + + // Access the order + $order = $cart->order; return response()->json([ 'success' => true, - 'purchases' => $purchases, - 'total_items' => $purchases->count(), + 'order' => $order, + 'order_number' => $order->order_number, ]); } catch (\Exception $e) { return response()->json([ @@ -209,28 +221,38 @@ try { } ``` +### What Happens During Checkout + +1. **Validates Cart** + - Checks that cart is not empty + - Validates all items have required information + - For booking products: validates dates are set + +2. **Claims Stock** + - Claims stock for booking/pool products + - Validates stock availability + +3. **Creates Order** + - Generates order number + - Creates Order record linked to cart + - Copies cart total to order amounts + +4. **Creates Purchases** + - Creates ProductPurchase records for each cart item + - Links purchases to order + +5. **Converts Cart** + - Marks cart as CONVERTED + - Sets `converted_at` timestamp + ### Important Notes -- Checkout validates stock availability for all items -- Creates `ProductPurchase` records for each cart item -- Decreases stock for each item -- Triggers product actions -- Marks cart as converted (`converted_at` timestamp) -- Removes cart items after successful checkout +- Stock is claimed at checkout time (not add-to-cart time for bookings) +- Cart items remain in database but are marked as converted +- Order is created with PENDING status by default ## Purchase History -### Check if User Purchased Product - -```php -$product = Product::find($productId); - -if ($user->hasPurchased($product)) { - // User has purchased this product - echo "You own this product!"; -} -``` - ### Get All Purchases ```php @@ -247,55 +269,226 @@ $productPurchases = $user->purchases() ->get(); ``` -### Purchase Statistics +## Order Management + +### Get All Orders ```php -$stats = $user->getPurchaseStats(); +// Get all orders +$orders = $user->orders()->get(); -// Returns: -// [ -// 'total_purchases' => 15, -// 'total_spent' => 450.00, -// 'total_items' => 23, -// 'cart_items' => 2, -// 'cart_total' => 89.99, -// ] +// Get orders with specific status +use Blax\Shop\Enums\OrderStatus; + +$pendingOrders = $user->pendingOrders()->get(); +$processingOrders = $user->processingOrders()->get(); +$completedOrders = $user->completedOrders()->get(); + +// Get active orders (not completed/cancelled/refunded) +$activeOrders = $user->activeOrders()->get(); +``` + +### Order Status Flow + +Orders progress through these statuses: + +1. **PENDING** - Order received but awaiting payment confirmation +2. **PROCESSING** - Payment received and order is being processed +3. **ON_HOLD** - Order on hold, awaiting further action +4. **IN_PREPARATION** - Order being prepared (packing, manufacturing) +5. **READY_FOR_PICKUP** - Order ready for pickup (for local pickup orders) +6. **SHIPPED** - Order has been shipped and is in transit +7. **DELIVERED** - Order delivered to customer +8. **COMPLETED** - Order complete, all actions fulfilled +9. **CANCELLED** - Order was cancelled +10. **REFUNDED** - Order was refunded +11. **FAILED** - Payment or processing failed + +### Get Order by Number + +```php +$order = $user->findOrderByNumber('ORD-2025-0001'); + +if ($order) { + echo "Order found: {$order->order_number}"; +} +``` + +### Order Details + +```php +$order = Order::find($orderId); + +// Order properties +$order->order_number; // Unique order number +$order->status; // OrderStatus enum +$order->amount_total; // Total amount (in cents) +$order->amount_paid; // Amount paid (in cents) +$order->amount_subtotal; // Subtotal before tax/shipping +$order->amount_tax; // Tax amount +$order->amount_shipping; // Shipping cost +$order->amount_discount; // Discount applied +$order->amount_refunded; // Amount refunded + +// Dates +$order->created_at; // When order was created +$order->paid_at; // When payment was received +$order->shipped_at; // When order was shipped +$order->delivered_at; // When order was delivered +$order->completed_at; // When order was completed +$order->cancelled_at; // When order was cancelled +$order->refunded_at; // When order was refunded + +// Additional info +$order->payment_method; // Payment method used +$order->payment_provider; // Payment provider (e.g., 'stripe') +$order->payment_reference; // Provider reference ID +$order->billing_address; // Billing address object +$order->shipping_address; // Shipping address object +$order->customer_note; // Customer's note +$order->internal_note; // Internal staff note +``` + +### Order Relationships + +```php +// Get order customer +$customer = $order->customer; + +// Get order purchases (line items) +$purchases = $order->purchases()->get(); + +// Get original cart +$cart = $order->cart; + +// Get order notes +$notes = $order->notes()->get(); +``` + +### Order Statistics + +```php +// Total spent across all orders +$totalSpent = $user->total_spent; // Accessor in cents + +// Number of orders +$orderCount = $user->order_count; + +// Number of completed orders +$completedCount = $user->completed_order_count; + +// Check if user has any orders +if ($user->hasOrders()) { + echo "Customer has placed orders"; +} + +// Check if user has active orders +if ($user->hasActiveOrders()) { + echo "Customer has orders in progress"; +} + +// Get latest order +$latestOrder = $user->latestOrder(); +``` + +### Filter Orders by Date + +```php +$from = Carbon::parse('2025-01-01'); +$to = Carbon::parse('2025-12-31'); + +$ordersThisYear = $user->ordersBetween($from, $to)->get(); +``` + +### Order Payment Status + +```php +// Check if order is paid +if ($order->is_paid) { + echo "Order has been paid"; +} + +// Check if fully paid +if ($order->is_fully_paid) { + echo "Order is fully paid"; +} + +// Get outstanding amount +$outstanding = $order->amount_outstanding; // In cents +``` + +### Update Order Status + +```php +use Blax\Shop\Enums\OrderStatus; + +// Update order status +$order->update(['status' => OrderStatus::PROCESSING]); + +// Mark as shipped +$order->update([ + 'status' => OrderStatus::SHIPPED, + 'shipped_at' => now(), +]); + +// Mark as delivered +$order->update([ + 'status' => OrderStatus::DELIVERED, + 'delivered_at' => now(), +]); + +// Mark as completed +$order->update([ + 'status' => OrderStatus::COMPLETED, + 'completed_at' => now(), +]); +``` + +### Add Order Notes + +```php +use Blax\Shop\Models\OrderNote; + +// Add customer-visible note +OrderNote::create([ + 'order_id' => $order->id, + 'content' => 'Your order has been shipped!', + 'is_customer_note' => true, +]); + +// Add internal note +OrderNote::create([ + 'order_id' => $order->id, + 'content' => 'Customer requested gift wrapping', + 'is_customer_note' => false, +]); + +// Get all notes +$allNotes = $order->notes()->get(); + +// Get customer-visible notes only +$customerNotes = $order->notes()->where('is_customer_note', true)->get(); ``` ## Refunds -### Refund a Purchase +### Refund an Order ```php -$purchase = ProductPurchase::find($purchaseId); +use Blax\Shop\Enums\OrderStatus; -try { - $success = $user->refundPurchase($purchase); - - if ($success) { - // Refund successful - // Stock has been returned - // Purchase status changed to 'refunded' - // Product 'refunded' actions triggered - - return response()->json([ - 'success' => true, - 'message' => 'Purchase refunded successfully', - ]); - } -} catch (\Exception $e) { - return response()->json([ - 'error' => $e->getMessage() - ], 400); -} +$order = Order::find($orderId); + +// Mark order as refunded +$order->update([ + 'status' => OrderStatus::REFUNDED, + 'refunded_at' => now(), + 'amount_refunded' => $order->amount_total, +]); + +// Stock will be released back from associated purchases ``` -### Important Notes - -- Only completed purchases can be refunded -- Stock is automatically returned to inventory -- Product actions with event 'refunded' are triggered - ## Cart Model ### Get Current Cart @@ -321,8 +514,8 @@ $cart->last_activity_at; // Last activity timestamp // Get cart items $items = $cart->items()->get(); -// Get cart purchases (if converted) -$purchases = $cart->purchases()->get(); +// Get cart order (if converted) +$order = $cart->order; // Get cart customer (user) $customer = $cart->customer; @@ -369,7 +562,7 @@ $cartItem = $cart->addToCart( ```php $purchase = ProductPurchase::find($purchaseId); -$purchase->status; // cart, pending, unpaid, completed, refunded +$purchase->status; // pending, unpaid, completed, refunded, failed $purchase->cart_id; // Associated cart ID $purchase->price_id; // Associated price ID $purchase->purchasable_id; // Product ID @@ -377,9 +570,11 @@ $purchase->purchasable_type; // Product class $purchase->purchaser_id; // User ID $purchase->purchaser_type; // User class $purchase->quantity; // Quantity purchased -$purchase->amount; // Total amount -$purchase->amount_paid; // Amount paid +$purchase->amount; // Total amount (in cents) +$purchase->amount_paid; // Amount paid (in cents) $purchase->charge_id; // Payment charge ID +$purchase->from; // Booking start date (for bookings) +$purchase->until; // Booking end date (for bookings) $purchase->meta; // Additional metadata ``` @@ -391,35 +586,44 @@ $product = $purchase->purchasable; // Get purchaser (user) $user = $purchase->purchaser; + +// Get associated cart item +$cartItem = $purchase->cartItem; + +// Get associated order +$order = $purchase->order; ``` ### Purchase Scopes ```php -// Get purchases in cart -$cartPurchases = ProductPurchase::inCart()->get(); +use Blax\Shop\Enums\PurchaseStatus; // Get completed purchases -$completed = ProductPurchase::completed()->get(); +$completed = ProductPurchase::where('status', PurchaseStatus::COMPLETED)->get(); -// Get purchases from specific cart -$cartPurchases = ProductPurchase::fromCart($cartId)->get(); +// Get pending purchases +$pending = ProductPurchase::where('status', PurchaseStatus::PENDING)->get(); ``` -## Stock Reservations +## Stock Claims -When adding products to cart, stock is automatically reserved: +When adding booking products to cart, stock is claimed at checkout time: ```php -// Stock is reserved when adding to cart -$cartItem = $user->addToCart($product, quantity: 2); +// For booking products, stock is NOT claimed when adding to cart +$cartItem = $user->addToCart($bookingProduct, quantity: 1, parameters: [ + 'from' => Carbon::parse('2025-01-15'), + 'until' => Carbon::parse('2025-01-20'), +]); -// Reservation is created automatically -// It expires after configured time (default: 15 minutes) -// Stock is released back when: -// - Reservation expires -// - Cart item is removed -// - Cart is abandoned +// Stock is validated and claimed during checkout +$cart = $user->currentCart(); +$cart->checkout(); // Claims stock at this point + +// For regular products, stock is claimed immediately when adding to cart +$cartItem = $user->addToCart($regularProduct, quantity: 2); +// Stock is claimed immediately for non-booking products ``` ## Error Handling @@ -491,10 +695,14 @@ Route::post('/checkout', function () { $user = auth()->user(); try { - $purchases = $user->checkoutCart(); + $cart = $user->currentCart(); + $cart->checkout(); - return redirect()->route('orders.success') - ->with('success', 'Order placed successfully!'); + // Access the created order + $order = $cart->order; + + return redirect()->route('orders.success', ['order' => $order->id]) + ->with('success', "Order {$order->order_number} placed successfully!"); } catch (\Exception $e) { return redirect()->back()->with('error', $e->getMessage()); } @@ -504,11 +712,25 @@ Route::post('/checkout', function () { Route::get('/orders', function () { $user = auth()->user(); - $purchases = $user->completedPurchases() - ->with('purchasable') + $orders = $user->orders() + ->with(['purchases.purchasable']) ->orderBy('created_at', 'desc') ->get(); - return view('orders.index', compact('purchases')); + return view('orders.index', compact('orders')); +}); + +// View specific order +Route::get('/orders/{order}', function (Order $order) { + $user = auth()->user(); + + // Ensure user owns this order + if ($order->customer_id !== $user->id) { + abort(403); + } + + $order->load(['purchases.purchasable', 'notes']); + + return view('orders.show', compact('order')); }); ``` diff --git a/docs/04-stripe-checkout.md b/docs/04-stripe-checkout.md index 128d8b1..979b66b 100644 --- a/docs/04-stripe-checkout.md +++ b/docs/04-stripe-checkout.md @@ -73,13 +73,16 @@ Redirect the user to the `url` to complete payment. GET /api/shop/stripe/success?session_id={SESSION_ID}&cart_id={CART_ID} ``` -When payment is successful: +When payment is successful (handled via webhook): - Cart status is updated to `CONVERTED` - Cart's `converted_at` is set -- ProductPurchases are updated with: +- Order is created from the cart (if not already exists) +- Payment is recorded on the order +- ProductPurchases are created with: - `status` → `COMPLETED` - `charge_id` → Stripe Payment Intent ID - - `amount_paid` → Amount from Stripe (in dollars, converted from cents) + - `amount` and `amount_paid` → Amount from Stripe (in cents) +- Order status changes to `PROCESSING` ### Cancel URL @@ -101,13 +104,31 @@ POST /api/shop/stripe/webhook The webhook handler processes the following Stripe events: -- `checkout.session.completed` - Updates cart to converted, updates purchases +**Checkout Session Events:** +- `checkout.session.completed` - Converts cart, creates order if needed, records payment - `checkout.session.async_payment_succeeded` - Same as completed -- `checkout.session.async_payment_failed` - Logs failure -- `charge.succeeded` - Updates purchases with charge info -- `charge.failed` - Marks purchases as `FAILED` -- `payment_intent.succeeded` - Updates purchases -- `payment_intent.payment_failed` - Marks purchases as `FAILED` +- `checkout.session.async_payment_failed` - Marks order as failed if exists +- `checkout.session.expired` - Adds note to order + +**Charge Events:** +- `charge.succeeded` - Updates purchases with charge info, records payment on order +- `charge.failed` - Marks purchases as `FAILED`, adds note to order +- `charge.refunded` - Records refund on order +- `charge.dispute.created` - Puts order on hold, adds dispute note +- `charge.dispute.closed` - Updates order based on dispute outcome + +**Payment Intent Events:** +- `payment_intent.succeeded` - Records payment on order +- `payment_intent.payment_failed` - Adds failure note to order +- `payment_intent.canceled` - Adds cancellation note + +**Refund Events:** +- `refund.created` - Records refund on order +- `refund.updated` - Updates refund information + +**Invoice Events** (for subscriptions): +- `invoice.payment_succeeded` - Handles subscription payments +- `invoice.payment_failed` - Handles failed subscription payments ### Configuring Webhook in Stripe @@ -148,14 +169,30 @@ Route::post('custom/stripe/webhook', [StripeWebhookController::class, 'handleWeb ->name('shop.stripe.webhook'); ``` -## ProductPurchase Updates +## ProductPurchase and Order Updates -The webhook handler automatically updates ProductPurchase records with charge information if the columns exist: +The webhook handler automatically updates ProductPurchase records and creates/updates Order records: +### Purchase Updates - `charge_id` - Stripe Payment Intent ID -- `amount_paid` - Amount paid in dollars +- `amount` - Amount in cents +- `amount_paid` - Amount paid in cents +- `status` - Updated to COMPLETED, FAILED, or REFUNDED based on event -These fields are automatically populated from the fillable array on the ProductPurchase model. +### Order Creation and Updates +When a checkout session is completed: +1. Cart is marked as CONVERTED +2. Order is created from cart (if doesn't exist) via `Order::createFromCart($cart)` +3. Payment is recorded on order via `$order->recordPayment($amount, $reference, 'stripe', 'stripe')` +4. Order status is updated to PROCESSING when payment is successful +5. OrderNote records are created for payment events + +These fields are automatically populated: +- `payment_reference` - Stripe Payment Intent ID +- `payment_method` - 'stripe' +- `payment_provider` - 'stripe' +- `amount_paid` - Amount paid in cents +- `paid_at` - Timestamp when payment was received ## Error Handling diff --git a/docs/05-product-relations.md b/docs/05-product-relations.md index 304b2ce..951d2d8 100644 --- a/docs/05-product-relations.md +++ b/docs/05-product-relations.md @@ -664,4 +664,3 @@ $products = Product::with([ - [Pool Products](./ProductTypes/02-pool-products.md) - POOL/SINGLE relations in detail - [Product Types](./ProductTypes/) - Understanding different product types -- [Stock Management](./06-stock-management.md) - How stock works with relations diff --git a/docs/ProductTypes/01-booking-products.md b/docs/ProductTypes/01-booking-products.md index a980c61..10e2140 100644 --- a/docs/ProductTypes/01-booking-products.md +++ b/docs/ProductTypes/01-booking-products.md @@ -316,4 +316,3 @@ $available = $product->getAvailableStock($date); - [Pool Products](./02-pool-products.md) - Managing groups of booking products - [Product Relations](../05-product-relations.md) - How products relate to each other -- [Stock Management](../06-stock-management.md) - Detailed stock system documentation diff --git a/docs/ProductTypes/02-pool-products.md b/docs/ProductTypes/02-pool-products.md index 53d505c..742d139 100644 --- a/docs/ProductTypes/02-pool-products.md +++ b/docs/ProductTypes/02-pool-products.md @@ -359,33 +359,77 @@ if (!$parkingPool->validatePoolConfiguration()['valid']) { ## Cart Integration -### Adding Pool to Cart +### Cart Item Tracking + +When a pool product is added to cart, the system tracks which specific single item is allocated: ```php $from = Carbon::parse('2025-01-15'); -$until = Carbon::parse('2025-01-17'); // 2 days +$until = Carbon::parse('2025-01-17'); $cartItem = $cart->addToCart($parkingPool, $quantity = 1, [], $from, $until); // Cart item properties: -// - purchasable: Pool Product +// - purchasable_id: Pool Product ID +// - purchasable_type: Product::class +// - product_id: Allocated Single Item ID (NEW!) // - quantity: 1 // - from: 2025-01-15 // - until: 2025-01-17 // - price: (unit_amount × 2 days) -// - meta->claimed_single_items: [spot_id] ``` -### Viewing Claimed Items +### Product ID Column + +The `product_id` column in cart_items table stores the specific single item allocated from the pool: ```php -$meta = $cartItem->getMeta(); -$claimedItemIds = $meta->claimed_single_items ?? []; +$cartItem->product_id; // ID of the allocated single item +$cartItem->purchasable_id; // ID of the pool product +$cartItem->purchasable; // The pool product itself +$cartItem->product; // The allocated single item -// Load the actual products -$claimedItems = Product::whereIn('id', $claimedItemIds)->get(); +// Get the effective product (allocated single or purchasable) +$effectiveProduct = $cartItem->getEffectiveProduct(); ``` +### Viewing Allocated Items + +```php +// Get the allocated single item +$allocatedSingle = Product::find($cartItem->product_id); + +// Or use the relationship +$allocatedSingle = $cartItem->product; + +// Pool product is still accessible +$poolProduct = $cartItem->purchasable; +``` + +### Date Changes and Reallocation + +When cart dates change, the system automatically reallocates pool items to optimize pricing: + +```php +// Update cart dates +$cart->setDates($newFrom, $newUntil); + +// Behind the scenes: +// 1. System calls reallocatePoolItems($newFrom, $newUntil) +// 2. For each pool item, finds available singles for new dates +// 3. Applies pricing strategy (LOWEST, HIGHEST, AVERAGE) +// 4. Reallocates to better-priced singles if available +// 5. Updates cart_item.product_id to new allocation +// 6. Recalculates prices based on new dates +``` + +The `reallocatePoolItems()` method: +- Checks availability of all single items for the new dates +- Applies the pool's pricing strategy +- Reassigns cart items to optimal single items +- Updates `product_id` column with new allocation +- Marks items as unavailable if no singles are available for the period + ### Removing from Cart ```php @@ -393,8 +437,8 @@ $cartItem->delete(); ``` **What happens:** -1. System finds claimed single items from metadata -2. Releases claims on each single item +1. System finds allocated single item from `product_id` column +2. Releases claims on the single item 3. Stock becomes available again ## Advanced Usage @@ -642,6 +686,22 @@ $pool->attachSingleItems($itemIds); // - Items → Pool (POOL) ``` +### Wrong Single Item Allocated + +**Cause:** Pricing strategy or date-based availability issue + +**Solution:** +```php +// Force reallocation by updating cart dates +$cart->setDates($from, $until, $overwrite = true); + +// Or manually check which single was allocated +$allocatedSingle = $cartItem->product; + +// Verify pricing strategy is correct +$strategy = $pool->getPricingStrategy(); +``` + ## Performance Considerations ### 1. Lazy Loading @@ -685,5 +745,3 @@ $pools->each(function($pool) { - [Booking Products](./01-booking-products.md) - Understanding single items in pools - [Product Relations](../05-product-relations.md) - Relation system details -- [Pricing Strategies](../07-pricing-strategies.md) - In-depth pricing documentation -- [Stock Management](../06-stock-management.md) - How stock system works diff --git a/src/Console/Commands/ShopAddExampleProducts.php b/src/Console/Commands/ShopAddExampleProducts.php index a1fcbb9..c2d509a 100644 --- a/src/Console/Commands/ShopAddExampleProducts.php +++ b/src/Console/Commands/ShopAddExampleProducts.php @@ -584,8 +584,11 @@ class ShopAddExampleProducts extends Command $prices = $productData['variation_prices'] ?? []; foreach ($variations as $index => $variation) { + $variationName = ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation; + $variationProduct = Product::create([ 'slug' => $product->slug . '-' . \Illuminate\Support\Str::slug($variation), + 'name' => $variationName, 'sku' => $product->sku . '-' . strtoupper(substr($variation, 0, 3)), 'type' => 'simple', 'parent_id' => $product->id, @@ -596,7 +599,7 @@ class ShopAddExampleProducts extends Command 'meta' => ['variation' => $variation, 'example' => true], ]); - $variationProduct->setLocalized('name', ($product->getLocalized('name') ?: 'Product') . ' - ' . $variation, null, true); + $variationProduct->setLocalized('name', $variationName, null, true); $variationAmount = $prices[$index] ?? ($basePrice + ($index * 500)); $variationProduct->prices()->create([ @@ -627,8 +630,11 @@ class ShopAddExampleProducts extends Command } foreach ($productData['grouped_items'] as $i => $item) { + $itemName = $item['name']; + $childProduct = Product::create([ 'slug' => $product->slug . '-item-' . ($i + 1), + 'name' => $itemName, 'sku' => $item['sku'], 'type' => 'simple', 'parent_id' => $product->id, @@ -639,7 +645,7 @@ class ShopAddExampleProducts extends Command 'meta' => ['grouped_item' => true, 'example' => true], ]); - $childProduct->setLocalized('name', $item['name'], null, true); + $childProduct->setLocalized('name', $itemName, null, true); $childProduct->prices()->create([ 'name' => 'Default', @@ -662,9 +668,11 @@ class ShopAddExampleProducts extends Command $parkingIds = []; foreach ($productData['pool_items'] as $i => $item) { + $itemName = $item['name']; + $parking = Product::create([ - 'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($item['name']), - 'name' => $item['name'], + 'slug' => $pool->slug . '-' . \Illuminate\Support\Str::slug($itemName), + 'name' => $itemName, 'sku' => $pool->sku . '-' . str_pad($i + 1, 2, '0', STR_PAD_LEFT), 'type' => ProductType::BOOKING, 'status' => ProductStatus::PUBLISHED, @@ -675,20 +683,26 @@ class ShopAddExampleProducts extends Command 'meta' => ['example' => true, 'pool_item' => true, 'parent_pool' => $pool->name], ]); + // Set localized name for consistency with other products + $parking->setLocalized('name', $itemName, null, true); + // Set stock for the parking spot $parking->increaseStock($item['stock']); // Create price for individual parking spot - $parking->prices()->create([ - 'name' => 'Default', - 'type' => 'one_time', - 'currency' => 'EUR', - 'unit_amount' => $item['price'], - 'is_default' => true, - 'active' => true, - 'billing_scheme' => 'per_unit', - 'meta' => ['example' => true], - ]); + // Note: If price is not provided, the pool's fallback price will be used during checkout + if (!empty($item['price'])) { + $parking->prices()->create([ + 'name' => 'Default', + 'type' => 'one_time', + 'currency' => 'EUR', + 'unit_amount' => $item['price'], + 'is_default' => true, + 'active' => true, + 'billing_scheme' => 'per_unit', + 'meta' => ['example' => true], + ]); + } $parkingIds[] = $parking->id; } @@ -703,10 +717,10 @@ class ShopAddExampleProducts extends Command // Get rooms $rooms = $this->createdProducts[ProductType::BOOKING->value] ?? []; - + // Get simple products (beverages) $beverages = $this->createdProducts[ProductType::SIMPLE->value] ?? []; - + // Get parking pools $parkingPools = $this->createdProducts[ProductType::POOL->value] ?? []; @@ -734,13 +748,13 @@ class ShopAddExampleProducts extends Command $rooms[0]->productRelations()->syncWithoutDetaching([ $rooms[1]->id => ['type' => ProductRelationType::UPSELL->value] ]); - + if (count($rooms) >= 3) { // Standard can also upsell to Presidential $rooms[0]->productRelations()->syncWithoutDetaching([ $rooms[2]->id => ['type' => ProductRelationType::UPSELL->value] ]); - + // Deluxe can upsell to Presidential $rooms[1]->productRelations()->syncWithoutDetaching([ $rooms[2]->id => ['type' => ProductRelationType::UPSELL->value] diff --git a/src/Http/Controllers/StripeWebhookController.php b/src/Http/Controllers/StripeWebhookController.php index d0d29eb..9a4de10 100644 --- a/src/Http/Controllers/StripeWebhookController.php +++ b/src/Http/Controllers/StripeWebhookController.php @@ -141,34 +141,45 @@ class StripeWebhookController ]); } - // Record payment on the associated order + // Get or create order from the cart $order = $cart->order; - if ($order) { - $amountPaid = (int) (($session->amount_total ?? 0) / 100); - $currency = strtoupper($session->currency ?? $order->currency ?? 'USD'); + if (!$order) { + // Create order from the converted cart + $order = Order::createFromCart($cart); - // recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider) - $order->recordPayment($amountPaid, $session->payment_intent, 'stripe', 'stripe'); - - // Add a detailed note - $order->addNote( - "Payment of " . Order::formatMoney($amountPaid, $currency) . " received via Stripe checkout (Session: {$session->id})", - OrderNote::TYPE_PAYMENT - ); - - // Mark order as processing if payment is successful - if ($session->payment_status === 'paid' && $order->status === OrderStatus::PENDING) { - $order->markAsProcessing('Payment received via Stripe checkout'); - } - - Log::info('Order payment recorded via Stripe checkout', [ + Log::info('Order created from Stripe checkout session', [ 'order_id' => $order->id, 'order_number' => $order->order_number, - 'amount' => $amountPaid, - 'currency' => $currency, + 'cart_id' => $cart->id, + 'session_id' => $session->id, ]); } + // Record payment on the order + $amountPaid = (int) (($session->amount_total ?? 0) / 100); + $currency = strtoupper($session->currency ?? $order->currency ?? 'USD'); + + // recordPayment(int $amount, ?string $reference, ?string $method, ?string $provider) + $order->recordPayment($amountPaid, $session->payment_intent, 'stripe', 'stripe'); + + // Add a detailed note + $order->addNote( + "Payment of " . Order::formatMoney($amountPaid, $currency) . " received via Stripe checkout (Session: {$session->id})", + OrderNote::TYPE_PAYMENT + ); + + // Mark order as processing if payment is successful + if ($session->payment_status === 'paid' && $order->status === OrderStatus::PENDING) { + $order->markAsProcessing('Payment received via Stripe checkout'); + } + + Log::info('Order payment recorded via Stripe checkout', [ + 'order_id' => $order->id, + 'order_number' => $order->order_number, + 'amount' => $amountPaid, + 'currency' => $currency, + ]); + return true; } diff --git a/src/Models/Cart.php b/src/Models/Cart.php index 457375a..22d80f7 100644 --- a/src/Models/Cart.php +++ b/src/Models/Cart.php @@ -573,8 +573,7 @@ class Cart extends Model // For pool products, check if allocated by reallocatePoolItems if ($product instanceof Product && $product->isPool()) { - $meta = $item->getMeta(); - $allocatedSingleItemId = $meta->allocated_single_item_id ?? null; + $allocatedSingleItemId = $item->product_id; // If this item was NOT allocated (no single assigned), skip updateDates // to preserve the null price set by reallocatePoolItems @@ -702,13 +701,13 @@ class Cart extends Model } // Clear allocation and set price to null to indicate unavailable - $cartItem->updateMetaKey('allocated_single_item_id', null); - $cartItem->updateMetaKey('allocated_single_item_name', null); $cartItem->update([ + 'product_id' => null, 'price' => null, 'subtotal' => null, 'unit_amount' => null, ]); + $cartItem->updateMetaKey('allocated_single_item_name', null); } continue; } @@ -747,12 +746,16 @@ class Cart extends Model if ($remainingFromSingle >= $neededQty) { // This single can accommodate the cart item's full quantity - $cartItem->updateMetaKey('allocated_single_item_id', $single->id); + // Update product_id to track the allocated single item + $updates = ['product_id' => $single->id]; + if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) { + $updates['price_id'] = $singleInfo['price_id']; + } + $cartItem->update($updates); $cartItem->updateMetaKey('allocated_single_item_name', $single->name); - // Update price_id if changed - if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) { - $cartItem->update(['price_id' => $singleInfo['price_id']]); + // Legacy: update price_id if changed (now handled in the update above) + if (false) { } // Track usage @@ -784,17 +787,17 @@ class Cart extends Model // Update the original cart item with reduced quantity // Also update subtotal to match the new quantity $newSubtotal = $cartItem->price * $qtyToAllocate; - $cartItem->update([ + $updates = [ 'quantity' => $qtyToAllocate, 'subtotal' => $newSubtotal, - ]); - $cartItem->refresh(); // Ensure model reflects database state - $cartItem->updateMetaKey('allocated_single_item_id', $single->id); - $cartItem->updateMetaKey('allocated_single_item_name', $single->name); - + 'product_id' => $single->id, + ]; if ($singleInfo['price_id'] && $singleInfo['price_id'] !== $cartItem->price_id) { - $cartItem->update(['price_id' => $singleInfo['price_id']]); + $updates['price_id'] = $singleInfo['price_id']; } + $cartItem->update($updates); + $cartItem->refresh(); // Ensure model reflects database state + $cartItem->updateMetaKey('allocated_single_item_name', $single->name); $firstAllocation = false; } else { @@ -814,6 +817,7 @@ class Cart extends Model $newCartItem = $this->items()->create([ 'purchasable_id' => $cartItem->purchasable_id, 'purchasable_type' => $cartItem->purchasable_type, + 'product_id' => $single->id, 'price_id' => $priceModel?->id, 'quantity' => $qtyToAllocate, 'price' => $pricePerUnit, @@ -825,7 +829,6 @@ class Cart extends Model 'until' => $until, ]); - $newCartItem->updateMetaKey('allocated_single_item_id', $single->id); $newCartItem->updateMetaKey('allocated_single_item_name', $single->name); } @@ -838,13 +841,13 @@ class Cart extends Model if ($remainingQty > 0) { if ($firstAllocation) { // Couldn't allocate anything - mark as unavailable - $cartItem->updateMetaKey('allocated_single_item_id', null); - $cartItem->updateMetaKey('allocated_single_item_name', null); $cartItem->update([ + 'product_id' => null, 'price' => null, 'subtotal' => null, 'unit_amount' => null, ]); + $cartItem->updateMetaKey('allocated_single_item_name', null); } else { // Partial allocation - the cart item was already updated with what we could allocate // The remaining quantity is lost (over-capacity) @@ -1219,9 +1222,8 @@ class Cart extends Model $expectedPrice = $poolItemData['price'] ?? null; $expectedSingleItemId = $poolItemData['item']?->id ?? null; - // Get the allocated single item ID from the existing cart item's meta - $existingMeta = $item->getMeta(); - $existingAllocatedItemId = $existingMeta->allocated_single_item_id ?? null; + // Get the allocated single item ID from the cart item's product_id column + $existingAllocatedItemId = $item->product_id; // Only merge if: // 1. price_id matches (same price source) @@ -1272,11 +1274,7 @@ class Cart extends Model $inCart = $this->items() ->where('purchasable_id', $cartable->getKey()) ->where('purchasable_type', get_class($cartable)) - ->get() - ->filter(function ($item) use ($single) { - $meta = $item->getMeta(); - return isset($meta->allocated_single_item_id) && $meta->allocated_single_item_id == $single->id; - }) + ->where('product_id', $single->id) ->sum('quantity'); if ($available === PHP_INT_MAX || $inCart < $available) { @@ -1360,6 +1358,7 @@ class Cart extends Model $cartItem = $this->items()->create([ 'purchasable_id' => $cartable->getKey(), 'purchasable_type' => get_class($cartable), + 'product_id' => ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) ? $poolSingleItem->id : null, 'price_id' => $priceId, 'quantity' => $quantity, 'price' => $pricePerUnit, // Price per unit for the period @@ -1371,9 +1370,8 @@ class Cart extends Model 'until' => ($is_booking) ? $until : null, ]); - // For pool products, store which single item is being used in meta + // For pool products, store the single item name in meta for display purposes if ($cartable instanceof Product && $cartable->isPool() && $poolSingleItem) { - $cartItem->updateMetaKey('allocated_single_item_id', $poolSingleItem->id); $cartItem->updateMetaKey('allocated_single_item_name', $poolSingleItem->name); } @@ -1808,7 +1806,7 @@ class Cart extends Model * d) If the product is a pool: * - If the pool contains booking single items, a timespan is required. * - When a timespan exists and booking singles are used, claim stock: - * - Use a pre-allocated single item from item meta (`allocated_single_item_id`) when present. + * - Use a pre-allocated single item from the `product_id` column when present. * - Otherwise call the pool stock claiming logic (`claimPoolStock`). * - Persist claimed single-item IDs into cart item meta (`claimed_single_items`). * e) If the product is a non-pool booking product, require a timespan. @@ -1885,13 +1883,12 @@ class Cart extends Model // If pool has timespan and has booking single items, claim stock from single items if ($from && $until && $product->hasBookingSingleItems()) { try { - // Check if we have pre-allocated single items from reallocation - $meta = $item->getMeta(); - $allocatedSingleId = $meta->allocated_single_item_id ?? null; + // Check if we have pre-allocated single items from product_id column + $allocatedSingleId = $item->product_id; if ($allocatedSingleId) { - // Use the pre-allocated single item - $singleItem = Product::find($allocatedSingleId); + // Use the pre-allocated single item from product_id + $singleItem = $item->product; if (!$singleItem) { throw new \Exception("Allocated single item not found: {$allocatedSingleId}"); } diff --git a/src/Models/CartItem.php b/src/Models/CartItem.php index eca38f7..aef9fb2 100644 --- a/src/Models/CartItem.php +++ b/src/Models/CartItem.php @@ -18,6 +18,7 @@ class CartItem extends Model 'cart_id', 'purchasable_id', 'purchasable_type', + 'product_id', 'price_id', 'quantity', 'price', @@ -96,10 +97,28 @@ class CartItem extends Model ); } - public function product(): BelongsTo|null + /** + * Get the actual product being purchased. + * For pool products, this is the single item allocated. + * For regular products, this returns the purchasable product itself. + */ + public function product(): BelongsTo { - if ($this->purchasable_type === config('shop.models.product', Product::class)) { - return $this->belongsTo(config('shop.models.product'), 'purchasable_id'); + return $this->belongsTo(config('shop.models.product', Product::class), 'product_id'); + } + + /** + * Get the effective product - either the allocated product_id or the purchasable. + * This is useful for getting the actual product when product_id may be null. + */ + public function getEffectiveProduct(): ?Product + { + if ($this->product_id) { + return $this->product; + } + + if ($this->purchasable instanceof Product) { + return $this->purchasable; } return null; @@ -482,12 +501,12 @@ class CartItem extends Model // For pool products with an allocated single, use the allocated single's price // This ensures consistency when reallocatePoolItems has already assigned a specific single - $meta = $this->getMeta(); - $allocatedSingleItemId = $meta->allocated_single_item_id ?? null; + // The product_id column stores the actual single product being purchased + $allocatedSingleItemId = $this->product_id; if ($product->isPool() && $allocatedSingleItemId) { - // Get the allocated single item - $allocatedSingle = Product::find($allocatedSingleItemId); + // Get the allocated single item from the product_id column + $allocatedSingle = $this->product; if ($allocatedSingle) { // Get price from the allocated single, with fallback to pool price diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index a0a4f3a..f948258 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -786,7 +786,7 @@ trait MayBePoolProduct } // Build usage map: track which single items have been allocated - // Use allocated_single_item_id from meta to track actual single item usage + // Use product_id column to track actual single item allocation // ONLY count items that overlap with the current booking period // Exclude the specified cart item (if updating dates on existing item) $singleItemUsage = []; // item_id => quantity used @@ -819,8 +819,8 @@ trait MayBePoolProduct } // else: no dates provided, count all items for progressive pricing - $meta = $item->getMeta(); - $allocatedItemId = $meta->allocated_single_item_id ?? null; + // Get the allocated single item ID from the product_id column + $allocatedItemId = $item->product_id; if ($allocatedItemId) { $singleItemUsage[$allocatedItemId] = ($singleItemUsage[$allocatedItemId] ?? 0) + $item->quantity; diff --git a/tests/Feature/BookingFeatureTest.php b/tests/Feature/Booking/BookingFeatureTest.php similarity index 100% rename from tests/Feature/BookingFeatureTest.php rename to tests/Feature/Booking/BookingFeatureTest.php diff --git a/tests/Feature/BookingPerMinutePricingTest.php b/tests/Feature/Booking/BookingPerMinutePricingTest.php similarity index 100% rename from tests/Feature/BookingPerMinutePricingTest.php rename to tests/Feature/Booking/BookingPerMinutePricingTest.php diff --git a/tests/Feature/BookingTimespanValidationTest.php b/tests/Feature/Booking/BookingTimespanValidationTest.php similarity index 100% rename from tests/Feature/BookingTimespanValidationTest.php rename to tests/Feature/Booking/BookingTimespanValidationTest.php diff --git a/tests/Feature/CartAddToCartPoolPricingTest.php b/tests/Feature/Cart/CartAddToCartPoolPricingTest.php similarity index 100% rename from tests/Feature/CartAddToCartPoolPricingTest.php rename to tests/Feature/Cart/CartAddToCartPoolPricingTest.php diff --git a/tests/Feature/CartCalendarAvailabilityTest.php b/tests/Feature/Cart/CartCalendarAvailabilityTest.php similarity index 100% rename from tests/Feature/CartCalendarAvailabilityTest.php rename to tests/Feature/Cart/CartCalendarAvailabilityTest.php diff --git a/tests/Feature/CartDateManagementTest.php b/tests/Feature/Cart/CartDateManagementTest.php similarity index 100% rename from tests/Feature/CartDateManagementTest.php rename to tests/Feature/Cart/CartDateManagementTest.php diff --git a/tests/Feature/CartDateStringParsingTest.php b/tests/Feature/Cart/CartDateStringParsingTest.php similarity index 100% rename from tests/Feature/CartDateStringParsingTest.php rename to tests/Feature/Cart/CartDateStringParsingTest.php diff --git a/tests/Feature/CartFacadeTest.php b/tests/Feature/Cart/CartFacadeTest.php similarity index 100% rename from tests/Feature/CartFacadeTest.php rename to tests/Feature/Cart/CartFacadeTest.php diff --git a/tests/Feature/CartItemAttributesTest.php b/tests/Feature/Cart/CartItemAttributesTest.php similarity index 100% rename from tests/Feature/CartItemAttributesTest.php rename to tests/Feature/Cart/CartItemAttributesTest.php diff --git a/tests/Feature/Cart/CartItemAvailabilityValidationTest.php b/tests/Feature/Cart/CartItemAvailabilityValidationTest.php new file mode 100644 index 0000000..d6807f7 --- /dev/null +++ b/tests/Feature/Cart/CartItemAvailabilityValidationTest.php @@ -0,0 +1,545 @@ +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); + } +} diff --git a/tests/Feature/CartItemDateManagementTest.php b/tests/Feature/Cart/CartItemDateManagementTest.php similarity index 100% rename from tests/Feature/CartItemDateManagementTest.php rename to tests/Feature/Cart/CartItemDateManagementTest.php diff --git a/tests/Feature/CartItemRequiredAdjustmentsTest.php b/tests/Feature/Cart/CartItemRequiredAdjustmentsTest.php similarity index 100% rename from tests/Feature/CartItemRequiredAdjustmentsTest.php rename to tests/Feature/Cart/CartItemRequiredAdjustmentsTest.php diff --git a/tests/Feature/CartManagementTest.php b/tests/Feature/Cart/CartManagementTest.php similarity index 100% rename from tests/Feature/CartManagementTest.php rename to tests/Feature/Cart/CartManagementTest.php diff --git a/tests/Feature/CartServiceBookingTest.php b/tests/Feature/Cart/CartServiceBookingTest.php similarity index 100% rename from tests/Feature/CartServiceBookingTest.php rename to tests/Feature/Cart/CartServiceBookingTest.php diff --git a/tests/Feature/GuestCartTest.php b/tests/Feature/Cart/GuestCartTest.php similarity index 100% rename from tests/Feature/GuestCartTest.php rename to tests/Feature/Cart/GuestCartTest.php diff --git a/tests/Feature/CartItemAvailabilityValidationTest.php b/tests/Feature/CartItemAvailabilityValidationTest.php index 4882b8c..d6807f7 100644 --- a/tests/Feature/CartItemAvailabilityValidationTest.php +++ b/tests/Feature/CartItemAvailabilityValidationTest.php @@ -126,13 +126,13 @@ class CartItemAvailabilityValidationTest extends TestCase $this->cart->addToCart($pool, 3, [], $from, $until); // Manually simulate an item becoming unavailable: - // - Remove allocation + // - Remove allocation (product_id = null) // - Set price to null (the real indicator of unavailability) $item = $this->cart->items()->first(); $meta = $item->getMeta(); - unset($meta->allocated_single_item_id); unset($meta->allocated_single_item_name); $item->update([ + 'product_id' => null, 'meta' => json_encode($meta), 'price' => null, 'subtotal' => null, @@ -245,8 +245,7 @@ class CartItemAvailabilityValidationTest extends TestCase // Verify all items are allocated and ready foreach ($this->cart->items as $item) { - $meta = $item->getMeta(); - $this->assertNotNull($meta->allocated_single_item_id ?? null, 'Item should be allocated'); + $this->assertNotNull($item->product_id, 'Item should have product_id allocated'); $this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready'); } diff --git a/tests/Feature/CartCheckoutSessionTest.php b/tests/Feature/Checkout/CartCheckoutSessionTest.php similarity index 100% rename from tests/Feature/CartCheckoutSessionTest.php rename to tests/Feature/Checkout/CartCheckoutSessionTest.php diff --git a/tests/Feature/CheckoutStockValidationTest.php b/tests/Feature/Checkout/CheckoutStockValidationTest.php similarity index 100% rename from tests/Feature/CheckoutStockValidationTest.php rename to tests/Feature/Checkout/CheckoutStockValidationTest.php diff --git a/tests/Feature/OrderCheckoutFlowTest.php b/tests/Feature/Checkout/OrderCheckoutFlowTest.php similarity index 100% rename from tests/Feature/OrderCheckoutFlowTest.php rename to tests/Feature/Checkout/OrderCheckoutFlowTest.php diff --git a/tests/Feature/PaymentMethodFieldsTest.php b/tests/Feature/Checkout/PaymentMethodFieldsTest.php similarity index 100% rename from tests/Feature/PaymentMethodFieldsTest.php rename to tests/Feature/Checkout/PaymentMethodFieldsTest.php diff --git a/tests/Feature/PaymentProviderTest.php b/tests/Feature/Checkout/PaymentProviderTest.php similarity index 100% rename from tests/Feature/PaymentProviderTest.php rename to tests/Feature/Checkout/PaymentProviderTest.php diff --git a/tests/Feature/PurchaseFlowTest.php b/tests/Feature/Checkout/PurchaseFlowTest.php similarity index 100% rename from tests/Feature/PurchaseFlowTest.php rename to tests/Feature/Checkout/PurchaseFlowTest.php diff --git a/tests/Feature/PoolAvailabilityMethodsTest.php b/tests/Feature/Pool/PoolAvailabilityMethodsTest.php similarity index 100% rename from tests/Feature/PoolAvailabilityMethodsTest.php rename to tests/Feature/Pool/PoolAvailabilityMethodsTest.php diff --git a/tests/Feature/PoolBookingDetectionTest.php b/tests/Feature/Pool/PoolBookingDetectionTest.php similarity index 100% rename from tests/Feature/PoolBookingDetectionTest.php rename to tests/Feature/Pool/PoolBookingDetectionTest.php diff --git a/tests/Feature/PoolClaimingPriorityTest.php b/tests/Feature/Pool/PoolClaimingPriorityTest.php similarity index 100% rename from tests/Feature/PoolClaimingPriorityTest.php rename to tests/Feature/Pool/PoolClaimingPriorityTest.php diff --git a/tests/Feature/PoolMaxQuantityValidationTest.php b/tests/Feature/Pool/PoolMaxQuantityValidationTest.php similarity index 100% rename from tests/Feature/PoolMaxQuantityValidationTest.php rename to tests/Feature/Pool/PoolMaxQuantityValidationTest.php diff --git a/tests/Feature/PoolParkingCartPricingTest.php b/tests/Feature/Pool/PoolParkingCartPricingTest.php similarity index 100% rename from tests/Feature/PoolParkingCartPricingTest.php rename to tests/Feature/Pool/PoolParkingCartPricingTest.php diff --git a/tests/Feature/PoolPerMinutePricingTest.php b/tests/Feature/Pool/PoolPerMinutePricingTest.php similarity index 100% rename from tests/Feature/PoolPerMinutePricingTest.php rename to tests/Feature/Pool/PoolPerMinutePricingTest.php diff --git a/tests/Feature/PoolProductCheckoutTest.php b/tests/Feature/Pool/PoolProductCheckoutTest.php similarity index 100% rename from tests/Feature/PoolProductCheckoutTest.php rename to tests/Feature/Pool/PoolProductCheckoutTest.php diff --git a/tests/Feature/Pool/PoolProductPriceIdTest.php b/tests/Feature/Pool/PoolProductPriceIdTest.php new file mode 100644 index 0000000..d7a5fa1 --- /dev/null +++ b/tests/Feature/Pool/PoolProductPriceIdTest.php @@ -0,0 +1,232 @@ +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 + } +} diff --git a/tests/Feature/PoolProductPricingFlexibilityTest.php b/tests/Feature/Pool/PoolProductPricingFlexibilityTest.php similarity index 100% rename from tests/Feature/PoolProductPricingFlexibilityTest.php rename to tests/Feature/Pool/PoolProductPricingFlexibilityTest.php diff --git a/tests/Feature/PoolProductPricingTest.php b/tests/Feature/Pool/PoolProductPricingTest.php similarity index 100% rename from tests/Feature/PoolProductPricingTest.php rename to tests/Feature/Pool/PoolProductPricingTest.php diff --git a/tests/Feature/PoolProductRelationsTest.php b/tests/Feature/Pool/PoolProductRelationsTest.php similarity index 100% rename from tests/Feature/PoolProductRelationsTest.php rename to tests/Feature/Pool/PoolProductRelationsTest.php diff --git a/tests/Feature/PoolProductStockTest.php b/tests/Feature/Pool/PoolProductStockTest.php similarity index 100% rename from tests/Feature/PoolProductStockTest.php rename to tests/Feature/Pool/PoolProductStockTest.php diff --git a/tests/Feature/PoolProductTest.php b/tests/Feature/Pool/PoolProductTest.php similarity index 100% rename from tests/Feature/PoolProductTest.php rename to tests/Feature/Pool/PoolProductTest.php diff --git a/tests/Feature/Pool/PoolProductionBugTest.php b/tests/Feature/Pool/PoolProductionBugTest.php new file mode 100644 index 0000000..96e6240 --- /dev/null +++ b/tests/Feature/Pool/PoolProductionBugTest.php @@ -0,0 +1,1105 @@ +setDates + */ +class PoolProductionBugTest extends TestCase +{ + protected User $user; + protected Cart $cart; + protected Product $pool; + protected array $singles; + + protected function setUp(): void + { + parent::setUp(); + + $this->user = User::factory()->create(); + auth()->login($this->user); + } + + /** + * Create the pool product matching production setup + * + * Pool default price: 5000 + * Singles: + * 1. price: 50000 + * 2. price: none (should fallback to pool price 5000) + * 3. price: none (should fallback to pool price 5000) + * 4. price: none (should fallback to pool price 5000) + * 5. price: 10001 + * 6. price: 10002 + */ + protected function createProductionPool(): void + { + // Create pool product with default price 5000 + $this->pool = Product::factory()->create([ + 'name' => 'Production Pool', + 'type' => ProductType::POOL, + 'manage_stock' => false, // Pool doesn't manage stock - it's the responsibility of single items + ]); + + // Pool default price: 5000 + ProductPrice::factory()->create([ + 'purchasable_id' => $this->pool->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 5000, + 'currency' => 'USD', + 'is_default' => true, + ]); + + // Set pricing strategy to lowest + $this->pool->setPoolPricingStrategy('lowest'); + + // Create 6 single items + $this->singles = []; + + // Single 1: price 50000 + $single1 = Product::factory()->create([ + 'name' => 'Single 1 - 50000', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single1->increaseStock(1); + ProductPrice::factory()->create([ + 'purchasable_id' => $single1->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 50000, + 'currency' => 'USD', + 'is_default' => true, + ]); + $this->singles[] = $single1; + + // Single 2: NO price (should fallback to pool price 5000) + $single2 = Product::factory()->create([ + 'name' => 'Single 2 - No Price', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single2->increaseStock(1); + $this->singles[] = $single2; + + // Single 3: NO price (should fallback to pool price 5000) + $single3 = Product::factory()->create([ + 'name' => 'Single 3 - No Price', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single3->increaseStock(1); + $this->singles[] = $single3; + + // Single 4: NO price (should fallback to pool price 5000) + $single4 = Product::factory()->create([ + 'name' => 'Single 4 - No Price', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single4->increaseStock(1); + $this->singles[] = $single4; + + // Single 5: price 10001 + $single5 = Product::factory()->create([ + 'name' => 'Single 5 - 10001', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single5->increaseStock(1); + ProductPrice::factory()->create([ + 'purchasable_id' => $single5->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10001, + 'currency' => 'USD', + 'is_default' => true, + ]); + $this->singles[] = $single5; + + // Single 6: price 10002 + $single6 = Product::factory()->create([ + 'name' => 'Single 6 - 10002', + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + $single6->increaseStock(1); + ProductPrice::factory()->create([ + 'purchasable_id' => $single6->id, + 'purchasable_type' => Product::class, + 'unit_amount' => 10002, + 'currency' => 'USD', + 'is_default' => true, + ]); + $this->singles[] = $single6; + + // Attach all singles to pool + $this->pool->attachSingleItems(array_map(fn($s) => $s->id, $this->singles)); + } + + protected function createCart(): Cart + { + return Cart::factory()->create([ + 'customer_id' => $this->user->id, + 'customer_type' => get_class($this->user), + ]); + } + + #[Test] + public function pool_max_quantity_returns_sum_of_single_item_stocks() + { + $this->createProductionPool(); + + // Total stock should be 6 (1 per single item) + $maxQty = $this->pool->getPoolMaxQuantity(); + + $this->assertEquals(6, $maxQty); + } + + #[Test] + public function adding_7_items_should_throw_not_enough_stock_exception() + { + $this->createProductionPool(); + $this->cart = $this->createCart(); + + // With new flexible cart behavior: adding without dates is allowed + // Exception should only be thrown when DATES are provided and there isn't enough stock + $from = now()->addDays(10); + $until = now()->addDays(12); + + // Adding 7 items with dates should throw exception since we only have 6 single items + $this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class); + $this->cart->addToCart($this->pool, 7, [], $from, $until); + } + + #[Test] + public function adding_6_items_gives_correct_progressive_pricing() + { + $this->createProductionPool(); + $this->cart = $this->createCart(); + + // Add 6 items one at a time to verify progressive pricing + // Expected order (LOWEST strategy): + // 1. 5000 (single 2,3,4 using pool fallback - first one) + // 2. 5000 (single 2,3,4 using pool fallback - second one) + // 3. 5000 (single 2,3,4 using pool fallback - third one) + // 4. 10001 (single 5) + // 5. 10002 (single 6) + // 6. 50000 (single 1) + + $cartItem1 = $this->cart->addToCart($this->pool, 1); + $this->assertEquals(5000, $cartItem1->price); + $this->assertEquals(5000, $this->cart->fresh()->getTotal()); + + $cartItem2 = $this->cart->addToCart($this->pool, 1); + $this->assertEquals(5000, $cartItem2->price); + $this->assertEquals(10000, $this->cart->fresh()->getTotal()); + + $cartItem3 = $this->cart->addToCart($this->pool, 1); + $this->assertEquals(5000, $cartItem3->price); + $this->assertEquals(15000, $this->cart->fresh()->getTotal()); + + $cartItem4 = $this->cart->addToCart($this->pool, 1); + $this->assertEquals(10001, $cartItem4->price); + $this->assertEquals(25001, $this->cart->fresh()->getTotal()); + + $cartItem5 = $this->cart->addToCart($this->pool, 1); + $this->assertEquals(10002, $cartItem5->price); + $this->assertEquals(35003, $this->cart->fresh()->getTotal()); + + $cartItem6 = $this->cart->addToCart($this->pool, 1); + $this->assertEquals(50000, $cartItem6->price); + $this->assertEquals(85003, $this->cart->fresh()->getTotal()); + } + + #[Test] + public function adding_6_items_at_once_gives_correct_pricing() + { + $this->createProductionPool(); + $this->cart = $this->createCart(); + + // Adding 6 items at once should give same total as adding one at a time + // Expected: 3x5000 + 10001 + 10002 + 50000 = 85003 + $this->cart->addToCart($this->pool, 6); + + $this->assertEquals(85003, $this->cart->fresh()->getTotal()); + } + + #[Test] + public function cart_items_have_correct_allocated_single_items() + { + $this->createProductionPool(); + $this->cart = $this->createCart(); + + $this->cart->addToCart($this->pool, 6); + + $items = $this->cart->fresh()->items->sortBy('price'); + + // Should have 4-6 cart items (depending on whether same-price items are merged) + // The 3x 5000 items might be merged since they have the same price_id (pool price) + // But different single items should NOT be merged + + // Get all allocated single item names + $allocatedNames = $items->map(fn($item) => [ + 'name' => $item->getMeta()->allocated_single_item_name ?? 'unknown', + 'price' => $item->price, + 'quantity' => $item->quantity, + ])->toArray(); + + // Total quantity should be 6 + $totalQuantity = $items->sum('quantity'); + $this->assertEquals(6, $totalQuantity); + + // Total price should be 85003 + $this->assertEquals(85003, $this->cart->getTotal()); + } + + #[Test] + public function set_dates_updates_cart_item_dates_and_recalculates_prices() + { + $this->createProductionPool(); + $this->cart = $this->createCart(); + + $from1 = Carbon::tomorrow()->startOfDay(); + $until1 = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day + + // Add items with initial dates + $this->cart->addToCart($this->pool, 3, [], $from1, $until1); + + // Verify initial state - 3 items at 5000 each + $initialTotal = $this->cart->fresh()->getTotal(); + $this->assertEquals(15000, $initialTotal); + + // Change to 2 day booking + $from2 = Carbon::tomorrow()->startOfDay(); + $until2 = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days + + $this->cart->setDates($from2, $until2); + + // Reload cart + $cart = $this->cart->fresh(); + $cart->load('items'); + + // Each cart item should now have: + // - updated from/until dates + // - doubled price (2 days instead of 1) + foreach ($cart->items as $item) { + $this->assertEquals($from2->format('Y-m-d H:i:s'), $item->from->format('Y-m-d H:i:s')); + $this->assertEquals($until2->format('Y-m-d H:i:s'), $item->until->format('Y-m-d H:i:s')); + // Price should be doubled (2 days) + $this->assertEquals(10000, $item->price, "Item price should be 10000 (5000 * 2 days)"); + } + + // Total should be doubled: 15000 * 2 = 30000 + $this->assertEquals(30000, $cart->getTotal()); + } + + #[Test] + public function set_dates_updates_all_items_with_different_prices() + { + $this->createProductionPool(); + $this->cart = $this->createCart(); + + $from1 = Carbon::tomorrow()->startOfDay(); + $until1 = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day + + // Add 6 items with initial 1-day dates + $this->cart->addToCart($this->pool, 6, [], $from1, $until1); + + // Verify initial state + $this->assertEquals(85003, $this->cart->fresh()->getTotal()); + + // Change to 2 day booking + $from2 = Carbon::tomorrow()->startOfDay(); + $until2 = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days + + $this->cart->setDates($from2, $until2); + + // Reload cart + $cart = $this->cart->fresh(); + $cart->load('items'); + + // Each item should have updated dates + foreach ($cart->items as $item) { + $this->assertEquals($from2->format('Y-m-d H:i:s'), $item->from->format('Y-m-d H:i:s')); + $this->assertEquals($until2->format('Y-m-d H:i:s'), $item->until->format('Y-m-d H:i:s')); + } + + // Total should be doubled: 85003 * 2 = 170006 + $this->assertEquals(170006, $cart->getTotal()); + } + + #[Test] + public function adding_items_without_dates_then_setting_dates_works() + { + $this->createProductionPool(); + $this->cart = $this->createCart(); + + // Add items WITHOUT dates + $this->cart->addToCart($this->pool, 3); + + // Initial total should be 15000 (3x 5000) + $this->assertEquals(15000, $this->cart->fresh()->getTotal()); + + // Now set dates for 2 days + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days + + $this->cart->setDates($from, $until); + + // Reload cart + $cart = $this->cart->fresh(); + $cart->load('items'); + + // Each cart item should now have dates and doubled prices + foreach ($cart->items as $item) { + $this->assertEquals($from->format('Y-m-d H:i:s'), $item->from->format('Y-m-d H:i:s')); + $this->assertEquals($until->format('Y-m-d H:i:s'), $item->until->format('Y-m-d H:i:s')); + // Price should be doubled (2 days) + $this->assertEquals(10000, $item->price, "Item price should be 10000 (5000 * 2 days)"); + } + + // Total should be 30000 (3x 5000 x 2 days) + $this->assertEquals(30000, $cart->getTotal()); + } + + /** + * If a user boys 5 single parking items, another can also buy 5 single items on different dates, + * but not on the same dates, if stock is claimed on date + */ + #[Test] + public function pool_allows_adding_singel_to_cart_again_after_booked() + { + $this->createProductionPool(); + $this->cart = $this->createCart(); + + $from1 = Carbon::tomorrow()->startOfDay(); + $until1 = Carbon::tomorrow()->addDay()->startOfDay(); // 1 day + + // First user books all 6 single items for specific dates + $this->cart->addToCart( + $this->pool, + 6, + [], + $from1, + $until1 + ); + + // Simulate checkout with positive purchase + $this->assertTrue($this->cart->isReadyForCheckout()); + $this->assertTrue($this->cart->IsReadyToCheckout); + $this->cart->checkout(); + + $this->assertGreaterThan(0, $this->cart->purchases()->count()); + + // Create a second cart for another user + $secondUser = User::factory()->create(); + $secondCart = $secondUser->currentCart(); + + // Second user adds items WITHOUT dates first + $secondCart->addToCart($this->pool, 6); + + $this->assertFalse($secondCart->isReadyForCheckout()); + $this->assertFalse($secondCart->IsReadyToCheckout); + + // Setting dates to a fully booked period should NOT throw, + // but mark items as unavailable instead + $secondCart->setDates($from1, $until1); + + // All items should be marked as unavailable + $secondCart->refresh(); + $secondCart->load('items'); + foreach ($secondCart->items as $item) { + $this->assertNull($item->price, 'Item should have null price for unavailable period'); + $this->assertFalse($item->is_ready_to_checkout); + } + $this->assertFalse($secondCart->isReadyForCheckout()); + + // Now second user tries different dates - should succeed + $from2 = Carbon::tomorrow()->addDays(2)->startOfDay(); + $until2 = Carbon::tomorrow()->addDays(3)->startOfDay(); // 1 day later + + // This should work - items become available again with new dates + $secondCart->setDates($from2, $until2); + $this->assertTrue($secondCart->isReadyForCheckout()); + $this->assertTrue($secondCart->isReadyToCheckout); + + $this->assertEquals(85003, $secondCart->fresh()->getTotal()); + + $secondCart->checkout(); + + $this->assertTrue($secondCart->fresh()->isConverted()); + } + + /** + * Production bug: After purchasing items via Stripe checkout for specific dates, + * user cannot add items to cart for DIFFERENT dates. + * + * Scenario: + * 1. User buys 5 singles from yesterday to in 2 days via Stripe checkout + * 2. Purchase is successful, webhooks handled, stock claimed for those dates + * 3. User should be able to add items to cart for DIFFERENT dates + * 4. But currently can only add 2 items (bug!) + * + * Expected: Should be able to add 6 items for different dates + * Actual: Can only add 2 items + */ + #[Test] + public function user_can_add_pool_items_for_different_dates_after_stripe_purchase() + { + $this->createProductionPool(); + $this->cart = $this->createCart(); + + // Simulate production scenario: purchase 5 items from yesterday to in 2 days + $purchasedFrom = Carbon::yesterday()->startOfDay(); + $purchasedUntil = Carbon::tomorrow()->addDay()->startOfDay(); // in 2 days + + // Add 5 items to cart with those dates + $this->cart->addToCart($this->pool, 5, [], $purchasedFrom, $purchasedUntil); + + // Simulate Stripe checkout flow (not regular checkout) + // This creates PENDING purchases and then webhook claims stock + $this->simulateStripeCheckout($this->cart, $purchasedFrom, $purchasedUntil); + + // Verify the cart is now converted + $this->assertTrue($this->cart->fresh()->isConverted()); + + // Now user creates a NEW cart for DIFFERENT dates + $newCart = $this->user->currentCart(); + $this->assertNotEquals($this->cart->id, $newCart->id, 'Should create a new cart after previous one is converted'); + + // Try to add 6 items for completely different dates + $newFrom = Carbon::tomorrow()->addDays(5)->startOfDay(); + $newUntil = Carbon::tomorrow()->addDays(6)->startOfDay(); + + // This should work - we should be able to add all 6 items for different dates + $newCart->addToCart($this->pool, 6, [], $newFrom, $newUntil); + + // Verify we got all 6 items + $newCart = $newCart->fresh(); + $this->assertEquals(6, $newCart->items->sum('quantity')); + $this->assertEquals(85003, $newCart->getTotal()); + $this->assertTrue($newCart->fresh()->isReadyForCheckout()); + } + + /** + * Helper to simulate Stripe checkout flow + * This mimics what happens when using checkoutSession() and webhook handler + */ + protected function simulateStripeCheckout(Cart $cart, $from, $until) + { + // Step 1: checkoutSession() creates PENDING purchases (without claiming stock yet) + foreach ($cart->items as $item) { + $product = $item->purchasable; + + $purchase = \Blax\Shop\Models\ProductPurchase::create([ + 'cart_id' => $cart->id, + 'price_id' => $item->price_id, + 'purchasable_id' => $product->id, + 'purchasable_type' => get_class($product), + 'purchaser_id' => $cart->customer_id, + 'purchaser_type' => $cart->customer_type, + 'quantity' => $item->quantity, + 'amount' => $item->subtotal, + 'amount_paid' => 0, + 'status' => \Blax\Shop\Enums\PurchaseStatus::PENDING, + 'from' => $from, + 'until' => $until, + 'meta' => $item->meta, + ]); + + $item->update(['purchase_id' => $purchase->id]); + } + + // Step 2: Webhook handler marks cart as converted and updates purchases to COMPLETED + $cart->update([ + 'status' => \Blax\Shop\Enums\CartStatus::CONVERTED, + 'converted_at' => now(), + ]); + + // Step 3: Webhook handler claims stock for each purchase + $purchases = \Blax\Shop\Models\ProductPurchase::where('cart_id', $cart->id)->get(); + foreach ($purchases as $purchase) { + $purchase->update([ + 'status' => \Blax\Shop\Enums\PurchaseStatus::COMPLETED, + 'amount_paid' => $purchase->amount, + ]); + + // Claim stock (this is what the webhook handler does) + $product = $purchase->purchasable; + if ($product instanceof Product && $product->isPool() && $purchase->from && $purchase->until) { + $product->claimPoolStock( + $purchase->quantity, + $purchase, + $purchase->from, + $purchase->until, + "Purchase #{$purchase->id} completed" + ); + } + } + } + + public function test_date_adjustment_with_one_item() + { + $this->createProductionPool(); + + $cart = $this->createCart(); + + $cart->addToCart( + $this->pool, + 1 + ); + + $this->assertEquals(5000, $cart->getTotal()); + $this->assertFalse($cart->isReadyForCheckout()); + $this->assertFalse($cart->items()->first()->is_ready_to_checkout); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + + $cart->setDates($from, $until); + + $this->assertEquals(5000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $until->subHours(5); + $cart->setUntilDate($until); + $this->assertLessThan(5000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $until->addHours(24); + $cart->setUntilDate($until); + $this->assertGreaterThan(5000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + } + + public function test_date_adjustment_with_one_item_day_adjustment() + { + // The hotel has parking plots (proxied with the pool) + $pool = Product::factory()->create([ + 'type' => ProductType::POOL, + 'manage_stock' => true, + ]); + + // In this hotel we have 3 cheap parking plots far from the entrance + $single_1 = Product::factory() + ->withStocks(3) + ->withPrices(1, 1000) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + + // 1 medium priced parking plots closer to the entrance + $single_2 = Product::factory() + ->withStocks(1) + ->withPrices(1, 10001) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + + // 1 premium parking plot right at the entrance + $single_3 = Product::factory() + ->withStocks(1) + ->withPrices(1, 10002) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + ]); + + $pool->attachSingleItems([ + $single_1->id, + $single_2->id, + $single_3->id, + ]); + + $cart = $this->createCart(); + + // We check nothing is in the cart + $this->assertEquals(0, $cart->items()->count()); + + // We add the pool to the cart and expect the cheapest option to be added + $cart->addToCart( + $pool, + 1 + ); + + $this->assertEquals(1000, $cart->getTotal()); + $this->assertFalse($cart->isReadyForCheckout()); + $this->assertFalse($cart->items()->first()->is_ready_to_checkout); + + $from = Carbon::tomorrow()->startOfDay(); + + $cart->setFromDate($from); + + $until = Carbon::tomorrow()->addDay()->startOfDay(); + $cart->setUntilDate($until); + + $cart->refresh(); + + $this->assertEquals(24, $cart->from->diffInHours($cart->until)); + + $cart->setDates($cart->from, $cart->until); + + // As dates are now set, we expect the cart to be ready for checkout and it shows the correct total (unit_amount of price is for one day and we check for a full day) + $this->assertEquals(1000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->setDates($cart->from->copy(), $cart->until->copy()->addHours(24)); + + $cart->refresh(); + $this->assertEquals(48, $cart->from->diffInHours($cart->until)); + + // We expect the amount to be doubled now, as 2 days are booked + $this->assertEquals(2000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->addToCart( + $pool, + 1 + ); + + // We have the 2000 2 times now, as we book 2 days with quantity of 2 with unit amount of 1000 + $this->assertEquals(4000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->addToCart( + $pool, + 1 + ); + + $this->assertEquals(6000, $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->addToCart( + $pool, + 1 + ); + + $cart->refresh(); + + // We expect to have 2 days booked and quantity of 3 and as the cheapest option is out of stock now, + // the next one is taken (unit amount of 10001) + $this->assertEquals(48, $cart->from->diffInHours($cart->until)); + $this->assertEquals(6000 + (10001 * 2), $cart->getTotal()); + $this->assertTrue($cart->isReadyForCheckout()); + $this->assertTrue($cart->items()->first()->is_ready_to_checkout); + + $cart->addToCart( + $pool, + 1 + ); + + $this->assertEquals(6000 + (10001 * 2) + (10002 * 2), $cart->getTotal()); + + $cart->removeFromCart( + $pool, + 1 + ); + + $this->assertEquals(6000 + (10001 * 2), $cart->getTotal()); + + $single_1->adjustStock( + StockType::CLAIMED, + 1, + from: now()->subYear(), + until: now()->addYear(), + note: 'Booked' + ); + + // After claiming 1 stock from single_1, the capacity is reduced from 5 to 4. + // We currently have 4 items in cart (3 @ single_1, 1 @ single_2). + // When setDates is called, reallocation happens: + // - single_1 now only has 2 capacity (3-1 claim = 2) + // - 2 items can stay at single_1 + // - 1 item must move to single_3 (the only one with capacity) + // - 1 item stays at single_2 + // After reallocation: 2 @ single_1 (4000) + 1 @ single_2 (20002) + 1 @ single_3 (20004) = 44006 + + // Trigger reallocation by refreshing dates + $cart->setDates($cart->from, $cart->until); + $cart->refresh(); + + $this->assertEquals(4000 + (10001 * 2) + (10002 * 2), $cart->getTotal()); + + // Now try to add another item - this should fail because capacity is full (4 items, 4 capacity) + // This can throw either NotEnoughStockException or HasNoPriceException depending on + // which validation runs first. HasNoPriceException is thrown when no single items + // have available capacity to provide a price. + $exceptionThrown = false; + try { + $cart->addToCart( + $pool, + 1 + ); + } catch (\Blax\Shop\Exceptions\NotEnoughStockException $e) { + $exceptionThrown = true; + } catch (\Blax\Shop\Exceptions\HasNoPriceException $e) { + $exceptionThrown = true; + } + $this->assertTrue($exceptionThrown, 'Expected either NotEnoughStockException or HasNoPriceException'); + } + + /** + * Test that single item allocation is properly tracked when adding multiple pool items. + * Each single item should only be used up to its stock limit. + */ + public function test_single_item_allocation_respects_stock_limits() + { + $pool = Product::factory()->create([ + 'type' => ProductType::POOL, + 'manage_stock' => true, + ]); + + // single_1: 3 stock @ 1000/day + $single_1 = Product::factory() + ->withStocks(3) + ->withPrices(1, 1000) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single1-Cheap', + ]); + + // single_2: 1 stock @ 10001/day + $single_2 = Product::factory() + ->withStocks(1) + ->withPrices(1, 10001) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single2-Medium', + ]); + + // single_3: 1 stock @ 10002/day + $single_3 = Product::factory() + ->withStocks(1) + ->withPrices(1, 10002) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single3-Premium', + ]); + + $pool->attachSingleItems([ + $single_1->id, + $single_2->id, + $single_3->id, + ]); + + $cart = $this->createCart(); + + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDays(2)->startOfDay(); // 2 days + $cart->setDates($from, $until); + + // Add 4 items one by one, tracking each addition + $items = []; + for ($i = 1; $i <= 4; $i++) { + $item = $cart->addToCart($pool, 1); + $meta = $item->getMeta(); + $items[$i] = [ + 'id' => $item->id, + 'quantity' => $item->quantity, + 'price' => $item->price, + 'allocated_id' => $item->product_id, + 'allocated_name' => $meta->allocated_single_item_name ?? 'none', + ]; + } + + $cart->refresh(); + + // Debug: check all cart items + $cartItems = $cart->items; + $cartItemDetails = []; + $totalQuantity = 0; + foreach ($cartItems as $item) { + $meta = $item->getMeta(); + $cartItemDetails[] = [ + 'id' => $item->id, + 'quantity' => $item->quantity, + 'price' => $item->price, + 'allocated_id' => $item->product_id, + 'allocated_name' => $meta->allocated_single_item_name ?? 'none', + ]; + $totalQuantity += $item->quantity; + } + + // Total quantity should be 4 (may be in fewer cart items if merged) + $this->assertEquals( + 4, + $totalQuantity, + 'Should have total quantity of 4. Cart items: ' . json_encode($cartItemDetails) + ); + + // The issue: when items are merged, the allocation tracking might not work correctly + // Each distinct single item should NOT be merged with others + // Items from the SAME single CAN be merged (they have same price and same product_id) + + // Check that we have correct allocations: + // - 3 quantity allocated to single_1 + // - 1 quantity allocated to single_2 + $single1Quantity = 0; + $single2Quantity = 0; + $single3Quantity = 0; + + // Verify EACH cart item has product_id set + foreach ($cartItems as $item) { + $allocatedId = $item->product_id; + $this->assertNotNull( + $allocatedId, + 'Cart item id=' . $item->id . ' (qty=' . $item->quantity . ', price=' . $item->price . + ') should have product_id but has: null' + ); + + if ($allocatedId == $single_1->id) { + $single1Quantity += $item->quantity; + } elseif ($allocatedId == $single_2->id) { + $single2Quantity += $item->quantity; + } elseif ($allocatedId == $single_3->id) { + $single3Quantity += $item->quantity; + } + } + + $this->assertEquals( + 3, + $single1Quantity, + 'Should have 3 quantity from single_1. Cart: ' . json_encode($cartItemDetails) + ); + $this->assertEquals( + 1, + $single2Quantity, + 'Should have 1 quantity from single_2. Cart: ' . json_encode($cartItemDetails) + ); + $this->assertEquals( + 0, + $single3Quantity, + 'Should have 0 quantity from single_3 before adding 5th. Cart: ' . json_encode($cartItemDetails) + ); + + // Before adding item 5, test what getNextAvailablePoolItemWithPrice returns + $nextBefore = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertNotNull($nextBefore, 'Should have next available before adding item 5'); + $this->assertEquals( + $single_3->id, + $nextBefore['item']->id, + 'Before adding item 5: Next should be single_3 (single_1 and single_2 exhausted). ' . + 'Got: ' . $nextBefore['item']->name . ' (id=' . $nextBefore['item']->id . '). ' . + 'Price: ' . $nextBefore['price'] . '. ' . + 'Cart items: ' . json_encode($cartItemDetails) + ); + + // Check that after refreshing the pool model, we still get single_3 + $pool->refresh(); + $nextBeforeAfterRefresh = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertEquals( + $single_3->id, + $nextBeforeAfterRefresh['item']->id, + 'After pool refresh, should still get single_3' + ); + + // Now add 5th item + $cartItemsBeforeItem5 = $cart->fresh()->items; + + // Debug: Check what getNextAvailablePoolItemWithPrice returns INSIDE the addToCart flow + // by calling it right before on a fresh pool and cart + $freshCart = Cart::find($cart->id); + $freshPool = Product::find($pool->id); + $nextImmediate = $freshPool->getNextAvailablePoolItemWithPrice($freshCart, null, $from, $until); + $this->assertEquals( + $single_3->id, + $nextImmediate['item']->id, + 'Immediately before addToCart (fresh models), should get single_3. Got: ' . + $nextImmediate['item']->name . ' (id=' . $nextImmediate['item']->id . ')' + ); + + // Debug: Check what the addToCart flow sees for cart items + // This replicates the query inside getNextAvailablePoolItemWithPrice + $cartItemsAsSeenByPool = $freshCart->items() + ->where('purchasable_id', $pool->getKey()) + ->where('purchasable_type', get_class($pool)) + ->get(); + $usageMap = []; + foreach ($cartItemsAsSeenByPool as $ci) { + $allocatedId = $ci->product_id; + if ($allocatedId) { + $usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity; + } + } + $this->assertEquals( + 3, + $usageMap[$single_1->id] ?? 0, + 'Usage map should show 3 for single_1. Map: ' . json_encode($usageMap) + ); + $this->assertEquals( + 1, + $usageMap[$single_2->id] ?? 0, + 'Usage map should show 1 for single_2. Map: ' . json_encode($usageMap) + ); + $this->assertEquals( + 0, + $usageMap[$single_3->id] ?? 0, + 'Usage map should show 0 for single_3. Map: ' . json_encode($usageMap) + ); + + // Use fresh cart AND fresh pool to call addToCart + $this->assertEquals($cart->id, $freshCart->id, 'Cart IDs should match'); + + // Debug: Check that freshCart can see the items + $freshCartItems = $freshCart->items() + ->where('purchasable_id', $pool->getKey()) + ->where('purchasable_type', get_class($pool)) + ->get(); + $this->assertCount( + 2, + $freshCartItems, + 'Fresh cart should have 2 cart item records. Cart ID: ' . $freshCart->id . + '. Items found: ' . $freshCartItems->pluck('id')->join(', ') + ); + $freshCartQty = $freshCartItems->sum('quantity'); + $this->assertEquals( + 4, + $freshCartQty, + 'Fresh cart should have total quantity 4. Got: ' . $freshCartQty + ); + + $item5 = $freshCart->addToCart($freshPool, 1); + + // Verify item 5 is allocated to single_3 (the only one with remaining capacity) + $this->assertEquals( + $single_3->id, + $item5->product_id, + 'Item 5 should be allocated to single_3 (id=' . $single_3->id . ') since single_1 and single_2 are exhausted. ' . + 'Got product_id: ' . ($item5->product_id ?? 'null') + ); + + // Check if item 5 is actually a new item or a merged item + $isNewItem = !$cartItemsBeforeItem5->contains('id', $item5->id); + $cartItemsAfterItem5 = $cart->fresh()->items; + + $this->assertTrue( + $isNewItem, + 'Item 5 should be a NEW item, not merged. ' . + 'Item5 id=' . $item5->id . ', quantity=' . $item5->quantity . '. ' . + 'Cart items before: ' . $cartItemsBeforeItem5->pluck('id')->join(', ') . '. ' . + 'Cart items after: ' . $cartItemsAfterItem5->pluck('id')->join(', ') + ); + + $this->assertEquals( + $single_3->id, + $item5->product_id, + 'Item 5 should be from single_3 (id=' . $single_3->id . ', name=' . $single_3->name . '). ' . + 'Got product_id: ' . ($item5->product_id ?? 'null') . '. ' . + 'For reference: single_1=' . $single_1->id . ', single_2=' . $single_2->id + ); + $this->assertEquals(20004, $item5->price, 'Item 5 should cost 20004 (10002 * 2 days)'); + + // Total: 3*2000 + 20002 + 20004 = 46006 + $this->assertEquals(46006, $cart->fresh()->getTotal()); + } + + /** + * Test getNextAvailablePoolItemWithPrice correctly tracks cart item allocations. + */ + public function test_get_next_available_pool_item_tracks_allocations() + { + $pool = Product::factory()->create([ + 'type' => ProductType::POOL, + 'manage_stock' => true, + ]); + + // single_1: 2 stock @ 1000/day + $single_1 = Product::factory() + ->withStocks(2) + ->withPrices(1, 1000) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single1', + ]); + + // single_2: 1 stock @ 2000/day + $single_2 = Product::factory() + ->withStocks(1) + ->withPrices(1, 2000) + ->create([ + 'type' => ProductType::BOOKING, + 'manage_stock' => true, + 'name' => 'Single2', + ]); + + $pool->attachSingleItems([$single_1->id, $single_2->id]); + + $cart = $this->createCart(); + $from = Carbon::tomorrow()->startOfDay(); + $until = Carbon::tomorrow()->addDay()->startOfDay(); + $cart->setDates($from, $until); + + // Before adding any items, next available should be single_1 (cheapest) + $next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertEquals($single_1->id, $next['item']->id, 'First available should be single_1'); + $this->assertEquals(1000, $next['price']); + + // Add first item - should get single_1 + $item1 = $cart->addToCart($pool, 1); + $this->assertEquals($single_1->id, $item1->product_id); + + // After 1 item, next should still be single_1 (has 2 stock) + $cart->refresh(); + $next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertEquals($single_1->id, $next['item']->id, 'Second available should still be single_1'); + + // Add second item - should get single_1 again + $item2 = $cart->addToCart($pool, 1); + $this->assertEquals($single_1->id, $item2->product_id); + + // After 2 items (both from single_1 which has stock=2), next should be single_2 + $cart->refresh(); + $next = $pool->getNextAvailablePoolItemWithPrice($cart, null, $from, $until); + $this->assertEquals($single_2->id, $next['item']->id, 'Third available should be single_2 (single_1 exhausted)'); + $this->assertEquals(2000, $next['price']); + + // Add third item - should get single_2 + $item3 = $cart->addToCart($pool, 1); + $this->assertEquals($single_2->id, $item3->product_id); + + // Total: 1000 + 1000 + 2000 = 4000 + $this->assertEquals(4000, $cart->fresh()->getTotal()); + } +} diff --git a/tests/Feature/PoolSeparateCartItemsTest.php b/tests/Feature/Pool/PoolSeparateCartItemsTest.php similarity index 100% rename from tests/Feature/PoolSeparateCartItemsTest.php rename to tests/Feature/Pool/PoolSeparateCartItemsTest.php diff --git a/tests/Feature/PoolSmartAllocationTest.php b/tests/Feature/Pool/PoolSmartAllocationTest.php similarity index 100% rename from tests/Feature/PoolSmartAllocationTest.php rename to tests/Feature/Pool/PoolSmartAllocationTest.php diff --git a/tests/Feature/PoolProductPriceIdTest.php b/tests/Feature/PoolProductPriceIdTest.php index ddeed9b..93a9bd9 100644 --- a/tests/Feature/PoolProductPriceIdTest.php +++ b/tests/Feature/PoolProductPriceIdTest.php @@ -128,7 +128,7 @@ class PoolProductPriceIdTest extends TestCase } #[Test] - public function it_stores_allocated_single_item_in_meta() + public function it_stores_allocated_single_item_in_product_id_column() { // Set pricing strategy to lowest $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); @@ -136,28 +136,28 @@ class PoolProductPriceIdTest extends TestCase // Add pool to cart $cartItem = $this->cart->addToCart($this->poolProduct, 1); - // Check meta contains allocated single item info + // Check product_id column contains allocated single item id + $this->assertNotNull($cartItem->product_id); + $this->assertEquals($this->singleItem1->id, $cartItem->product_id); + + // Meta should still have the name for display purposes $meta = $cartItem->getMeta(); - $this->assertNotNull($meta->allocated_single_item_id ?? null); - $this->assertEquals($this->singleItem1->id, $meta->allocated_single_item_id); $this->assertEquals($this->singleItem1->name, $meta->allocated_single_item_name); } #[Test] - public function it_stores_different_single_items_in_meta_for_progressive_pricing() + public function it_stores_different_single_items_in_product_id_for_progressive_pricing() { // Set pricing strategy to lowest $this->poolProduct->setPricingStrategy(PricingStrategy::LOWEST); // Add first pool item $cartItem1 = $this->cart->addToCart($this->poolProduct, 1); - $meta1 = $cartItem1->getMeta(); - $this->assertEquals($this->singleItem1->id, $meta1->allocated_single_item_id); + $this->assertEquals($this->singleItem1->id, $cartItem1->product_id); // Add second pool item $cartItem2 = $this->cart->addToCart($this->poolProduct, 1); - $meta2 = $cartItem2->getMeta(); - $this->assertEquals($this->singleItem2->id, $meta2->allocated_single_item_id); + $this->assertEquals($this->singleItem2->id, $cartItem2->product_id); } #[Test] @@ -183,13 +183,12 @@ class PoolProductPriceIdTest extends TestCase $this->assertEquals($poolPrice->id, $cartItem->price_id); $this->assertEquals(3000, $cartItem->price); - // Meta should indicate which single item was allocated + // product_id should indicate which single item was allocated // Even though the pool's price is used as fallback, one of the single items is still allocated - $meta = $cartItem->getMeta(); - $this->assertNotNull($meta->allocated_single_item_id ?? null); + $this->assertNotNull($cartItem->product_id); $this->assertTrue( - $meta->allocated_single_item_id === $this->singleItem1->id || - $meta->allocated_single_item_id === $this->singleItem2->id, + $cartItem->product_id === $this->singleItem1->id || + $cartItem->product_id === $this->singleItem2->id, 'Allocated single item should be one of the pool\'s single items' ); } diff --git a/tests/Feature/PoolProductionBugTest.php b/tests/Feature/PoolProductionBugTest.php index 24f1ac2..96e6240 100644 --- a/tests/Feature/PoolProductionBugTest.php +++ b/tests/Feature/PoolProductionBugTest.php @@ -838,7 +838,7 @@ class PoolProductionBugTest extends TestCase 'id' => $item->id, 'quantity' => $item->quantity, 'price' => $item->price, - 'allocated_id' => $meta->allocated_single_item_id ?? null, + 'allocated_id' => $item->product_id, 'allocated_name' => $meta->allocated_single_item_name ?? 'none', ]; } @@ -855,7 +855,7 @@ class PoolProductionBugTest extends TestCase 'id' => $item->id, 'quantity' => $item->quantity, 'price' => $item->price, - 'allocated_id' => $meta->allocated_single_item_id ?? null, + 'allocated_id' => $item->product_id, 'allocated_name' => $meta->allocated_single_item_name ?? 'none', ]; $totalQuantity += $item->quantity; @@ -870,7 +870,7 @@ class PoolProductionBugTest extends TestCase // The issue: when items are merged, the allocation tracking might not work correctly // Each distinct single item should NOT be merged with others - // Items from the SAME single CAN be merged (they have same price and same allocated_single_item_id) + // Items from the SAME single CAN be merged (they have same price and same product_id) // Check that we have correct allocations: // - 3 quantity allocated to single_1 @@ -879,14 +879,13 @@ class PoolProductionBugTest extends TestCase $single2Quantity = 0; $single3Quantity = 0; - // Verify EACH cart item has allocated_single_item_id set + // Verify EACH cart item has product_id set foreach ($cartItems as $item) { - $meta = $item->getMeta(); - $allocatedId = $meta->allocated_single_item_id ?? null; + $allocatedId = $item->product_id; $this->assertNotNull( $allocatedId, 'Cart item id=' . $item->id . ' (qty=' . $item->quantity . ', price=' . $item->price . - ') should have allocated_single_item_id but has: ' . json_encode($meta) + ') should have product_id but has: null' ); if ($allocatedId == $single_1->id) { @@ -958,8 +957,7 @@ class PoolProductionBugTest extends TestCase ->get(); $usageMap = []; foreach ($cartItemsAsSeenByPool as $ci) { - $meta = $ci->getMeta(); - $allocatedId = $meta->allocated_single_item_id ?? null; + $allocatedId = $ci->product_id; if ($allocatedId) { $usageMap[$allocatedId] = ($usageMap[$allocatedId] ?? 0) + $ci->quantity; } @@ -1002,14 +1000,13 @@ class PoolProductionBugTest extends TestCase ); $item5 = $freshCart->addToCart($freshPool, 1); - $meta5 = $item5->getMeta(); // Verify item 5 is allocated to single_3 (the only one with remaining capacity) $this->assertEquals( $single_3->id, - $meta5->allocated_single_item_id, + $item5->product_id, 'Item 5 should be allocated to single_3 (id=' . $single_3->id . ') since single_1 and single_2 are exhausted. ' . - 'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null') + 'Got product_id: ' . ($item5->product_id ?? 'null') ); // Check if item 5 is actually a new item or a merged item @@ -1026,9 +1023,9 @@ class PoolProductionBugTest extends TestCase $this->assertEquals( $single_3->id, - $meta5->allocated_single_item_id, + $item5->product_id, 'Item 5 should be from single_3 (id=' . $single_3->id . ', name=' . $single_3->name . '). ' . - 'Got allocated_id: ' . ($meta5->allocated_single_item_id ?? 'null') . '. ' . + 'Got product_id: ' . ($item5->product_id ?? 'null') . '. ' . 'For reference: single_1=' . $single_1->id . ', single_2=' . $single_2->id ); $this->assertEquals(20004, $item5->price, 'Item 5 should cost 20004 (10002 * 2 days)'); @@ -1081,7 +1078,7 @@ class PoolProductionBugTest extends TestCase // Add first item - should get single_1 $item1 = $cart->addToCart($pool, 1); - $this->assertEquals($single_1->id, $item1->getMeta()->allocated_single_item_id); + $this->assertEquals($single_1->id, $item1->product_id); // After 1 item, next should still be single_1 (has 2 stock) $cart->refresh(); @@ -1090,7 +1087,7 @@ class PoolProductionBugTest extends TestCase // Add second item - should get single_1 again $item2 = $cart->addToCart($pool, 1); - $this->assertEquals($single_1->id, $item2->getMeta()->allocated_single_item_id); + $this->assertEquals($single_1->id, $item2->product_id); // After 2 items (both from single_1 which has stock=2), next should be single_2 $cart->refresh(); @@ -1100,7 +1097,7 @@ class PoolProductionBugTest extends TestCase // Add third item - should get single_2 $item3 = $cart->addToCart($pool, 1); - $this->assertEquals($single_2->id, $item3->getMeta()->allocated_single_item_id); + $this->assertEquals($single_2->id, $item3->product_id); // Total: 1000 + 1000 + 2000 = 4000 $this->assertEquals(4000, $cart->fresh()->getTotal()); diff --git a/tests/Feature/ProductActionTest.php b/tests/Feature/Product/ProductActionTest.php similarity index 100% rename from tests/Feature/ProductActionTest.php rename to tests/Feature/Product/ProductActionTest.php diff --git a/tests/Feature/ProductAttributeTest.php b/tests/Feature/Product/ProductAttributeTest.php similarity index 100% rename from tests/Feature/ProductAttributeTest.php rename to tests/Feature/Product/ProductAttributeTest.php diff --git a/tests/Feature/ProductCategoryTest.php b/tests/Feature/Product/ProductCategoryTest.php similarity index 100% rename from tests/Feature/ProductCategoryTest.php rename to tests/Feature/Product/ProductCategoryTest.php diff --git a/tests/Feature/ProductManagementTest.php b/tests/Feature/Product/ProductManagementTest.php similarity index 100% rename from tests/Feature/ProductManagementTest.php rename to tests/Feature/Product/ProductManagementTest.php diff --git a/tests/Feature/ProductPriceTest.php b/tests/Feature/Product/ProductPriceTest.php similarity index 100% rename from tests/Feature/ProductPriceTest.php rename to tests/Feature/Product/ProductPriceTest.php diff --git a/tests/Feature/ProductPricingValidationTest.php b/tests/Feature/Product/ProductPricingValidationTest.php similarity index 100% rename from tests/Feature/ProductPricingValidationTest.php rename to tests/Feature/Product/ProductPricingValidationTest.php diff --git a/tests/Feature/ProductPurchaseTest.php b/tests/Feature/Product/ProductPurchaseTest.php similarity index 100% rename from tests/Feature/ProductPurchaseTest.php rename to tests/Feature/Product/ProductPurchaseTest.php diff --git a/tests/Feature/ProductScopeTest.php b/tests/Feature/Product/ProductScopeTest.php similarity index 100% rename from tests/Feature/ProductScopeTest.php rename to tests/Feature/Product/ProductScopeTest.php diff --git a/tests/Feature/ProductStockTest.php b/tests/Feature/Product/ProductStockTest.php similarity index 100% rename from tests/Feature/ProductStockTest.php rename to tests/Feature/Product/ProductStockTest.php diff --git a/tests/Feature/StockAttributesTest.php b/tests/Feature/Product/StockAttributesTest.php similarity index 100% rename from tests/Feature/StockAttributesTest.php rename to tests/Feature/Product/StockAttributesTest.php diff --git a/tests/Feature/StockManagementTest.php b/tests/Feature/Product/StockManagementTest.php similarity index 100% rename from tests/Feature/StockManagementTest.php rename to tests/Feature/Product/StockManagementTest.php diff --git a/tests/Feature/ProductionBugs/PoolPricingReallocationBugTest.php b/tests/Feature/ProductionBugs/PoolPricingReallocationBugTest.php new file mode 100644 index 0000000..e95895c --- /dev/null +++ b/tests/Feature/ProductionBugs/PoolPricingReallocationBugTest.php @@ -0,0 +1,748 @@ +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' + ); + } +} diff --git a/tests/Feature/StripeChargeFlowTest.php b/tests/Feature/Stripe/StripeChargeFlowTest.php similarity index 100% rename from tests/Feature/StripeChargeFlowTest.php rename to tests/Feature/Stripe/StripeChargeFlowTest.php diff --git a/tests/Unit/CartExpirationTest.php b/tests/Unit/Cart/CartExpirationTest.php similarity index 100% rename from tests/Unit/CartExpirationTest.php rename to tests/Unit/Cart/CartExpirationTest.php diff --git a/tests/Unit/CartItemTest.php b/tests/Unit/Cart/CartItemTest.php similarity index 100% rename from tests/Unit/CartItemTest.php rename to tests/Unit/Cart/CartItemTest.php diff --git a/tests/Unit/CartTest.php b/tests/Unit/Cart/CartTest.php similarity index 100% rename from tests/Unit/CartTest.php rename to tests/Unit/Cart/CartTest.php diff --git a/tests/Unit/HasOrdersTraitTest.php b/tests/Unit/Order/HasOrdersTraitTest.php similarity index 100% rename from tests/Unit/HasOrdersTraitTest.php rename to tests/Unit/Order/HasOrdersTraitTest.php diff --git a/tests/Unit/OrderNoteTest.php b/tests/Unit/Order/OrderNoteTest.php similarity index 100% rename from tests/Unit/OrderNoteTest.php rename to tests/Unit/Order/OrderNoteTest.php diff --git a/tests/Unit/OrderSummaryTest.php b/tests/Unit/Order/OrderSummaryTest.php similarity index 100% rename from tests/Unit/OrderSummaryTest.php rename to tests/Unit/Order/OrderSummaryTest.php diff --git a/tests/Unit/OrderTest.php b/tests/Unit/Order/OrderTest.php similarity index 100% rename from tests/Unit/OrderTest.php rename to tests/Unit/Order/OrderTest.php diff --git a/tests/Unit/ProductDuplicateTest.php b/tests/Unit/Product/ProductDuplicateTest.php similarity index 100% rename from tests/Unit/ProductDuplicateTest.php rename to tests/Unit/Product/ProductDuplicateTest.php diff --git a/tests/Unit/ProductPricingTest.php b/tests/Unit/Product/ProductPricingTest.php similarity index 100% rename from tests/Unit/ProductPricingTest.php rename to tests/Unit/Product/ProductPricingTest.php diff --git a/tests/Unit/ProductRelationsTest.php b/tests/Unit/Product/ProductRelationsTest.php similarity index 100% rename from tests/Unit/ProductRelationsTest.php rename to tests/Unit/Product/ProductRelationsTest.php diff --git a/tests/Unit/StripeWebhookOrderTest.php b/tests/Unit/Stripe/StripeWebhookOrderTest.php similarity index 70% rename from tests/Unit/StripeWebhookOrderTest.php rename to tests/Unit/Stripe/StripeWebhookOrderTest.php index 494decf..f3a887b 100644 --- a/tests/Unit/StripeWebhookOrderTest.php +++ b/tests/Unit/Stripe/StripeWebhookOrderTest.php @@ -550,4 +550,207 @@ class StripeWebhookOrderTest extends TestCase $this->assertNotNull($failNote); $this->assertStringContainsString('declined', $failNote->content); } + + // ========================================================================= + // STRIPE CHECKOUT SESSION FLOW TESTS (No pre-existing order) + // ========================================================================= + + #[Test] + public function checkout_session_completed_creates_order_when_none_exists() + { + $customer = User::factory()->create(); + $product = $this->createProduct(100.00); + + // Add to cart but DON'T call checkoutCart() - simulate checkoutSession() flow + $customer->addToCart($product); + $cart = $customer->currentCart(); + + // Verify no order exists yet + $this->assertNull($cart->order); + + // Simulate what checkoutSession() does: mark cart as converted + $cart->update([ + 'status' => CartStatus::CONVERTED, + 'converted_at' => now(), + ]); + + // Now simulate checkout session completed webhook + $session = $this->createMockSession([ + 'metadata' => (object) ['cart_id' => $cart->id], + 'amount_total' => 10000, // 100.00 + 'payment_status' => 'paid', + ]); + + $result = $this->invokeMethod('handleCheckoutSessionCompleted', [$session]); + + $this->assertTrue($result); + + // Verify order was created + $cart->refresh(); + $order = $cart->order; + $this->assertNotNull($order, 'Order should be created by webhook'); + $this->assertEquals($cart->id, $order->cart_id); + $this->assertEquals($customer->id, $order->customer_id); + } + + #[Test] + public function checkout_session_completed_creates_order_and_records_payment() + { + $customer = User::factory()->create(); + $product = $this->createProduct(150.00); + + // Add to cart but DON'T call checkoutCart() + $customer->addToCart($product); + $cart = $customer->currentCart(); + + // Simulate checkoutSession() conversion + $cart->update([ + 'status' => CartStatus::CONVERTED, + 'converted_at' => now(), + ]); + + $session = $this->createMockSession([ + 'metadata' => (object) ['cart_id' => $cart->id], + 'amount_total' => 15000, // 150.00 + 'payment_status' => 'paid', + 'payment_intent' => 'pi_stripe_checkout_test', + ]); + + $this->invokeMethod('handleCheckoutSessionCompleted', [$session]); + + $cart->refresh(); + $order = $cart->order; + + $this->assertNotNull($order); + $this->assertEquals(150.00, $order->amount_paid); + $this->assertEquals(OrderStatus::PROCESSING, $order->status); + } + + #[Test] + public function checkout_session_completed_creates_order_with_correct_totals() + { + $customer = User::factory()->create(); + $product = $this->createProduct(75.50); + + $customer->addToCart($product, 2); // 2 items = 151.00 + $cart = $customer->currentCart(); + + $cart->update([ + 'status' => CartStatus::CONVERTED, + 'converted_at' => now(), + ]); + + $session = $this->createMockSession([ + 'metadata' => (object) ['cart_id' => $cart->id], + 'amount_total' => 15100, // 151.00 + 'payment_status' => 'paid', + ]); + + $this->invokeMethod('handleCheckoutSessionCompleted', [$session]); + + $order = $cart->fresh()->order; + + $this->assertNotNull($order); + // Order total should match cart total (in cents) + $this->assertEquals((int) $cart->getTotal() * 100, $order->amount_total); + } + + #[Test] + public function checkout_session_completed_adds_payment_note_when_creating_order() + { + $customer = User::factory()->create(); + $product = $this->createProduct(50.00); + + $customer->addToCart($product); + $cart = $customer->currentCart(); + + $cart->update([ + 'status' => CartStatus::CONVERTED, + 'converted_at' => now(), + ]); + + $session = $this->createMockSession([ + 'metadata' => (object) ['cart_id' => $cart->id], + 'amount_total' => 5000, + 'payment_status' => 'paid', + 'payment_intent' => 'pi_test_payment_note', + ]); + + $this->invokeMethod('handleCheckoutSessionCompleted', [$session]); + + $order = $cart->fresh()->order; + + $this->assertNotNull($order); + + $paymentNote = $order->notes()->where('type', OrderNote::TYPE_PAYMENT)->first(); + $this->assertNotNull($paymentNote, 'Payment note should be created'); + $this->assertStringContainsString('50', $paymentNote->content); + $this->assertStringContainsString('Stripe checkout', $paymentNote->content); + } + + #[Test] + public function checkout_session_completed_does_not_duplicate_order() + { + $customer = User::factory()->create(); + $product = $this->createProduct(100.00); + + // Use checkoutCart() which creates an order + $customer->addToCart($product); + $cart = $customer->checkoutCart(); + + $existingOrder = $cart->fresh()->order; + $this->assertNotNull($existingOrder); + $originalOrderId = $existingOrder->id; + + // Now call webhook - should NOT create a duplicate order + $session = $this->createMockSession([ + 'metadata' => (object) ['cart_id' => $cart->id], + 'amount_total' => 10000, + 'payment_status' => 'paid', + ]); + + $this->invokeMethod('handleCheckoutSessionCompleted', [$session]); + + $cart->refresh(); + + // Should still be the same order + $this->assertEquals($originalOrderId, $cart->order->id); + + // Should only have one order for this cart + $orderCount = Order::where('cart_id', $cart->id)->count(); + $this->assertEquals(1, $orderCount); + } + + #[Test] + public function checkout_session_completed_without_prior_conversion_creates_order() + { + $customer = User::factory()->create(); + $product = $this->createProduct(200.00); + + // Add to cart - cart is NOT converted yet (simulates edge case) + $customer->addToCart($product); + $cart = $customer->currentCart(); + + $this->assertEquals(CartStatus::ACTIVE, $cart->status); + $this->assertNull($cart->order); + + // Webhook fires - should convert cart AND create order + $session = $this->createMockSession([ + 'metadata' => (object) ['cart_id' => $cart->id], + 'amount_total' => 20000, + 'payment_status' => 'paid', + ]); + + $this->invokeMethod('handleCheckoutSessionCompleted', [$session]); + + $cart->refresh(); + + // Cart should be converted + $this->assertEquals(CartStatus::CONVERTED, $cart->status); + $this->assertNotNull($cart->converted_at); + + // Order should exist + $this->assertNotNull($cart->order); + $this->assertEquals(200.00, $cart->order->amount_paid); + } }