BFI cart, A stripe logic, checkout & tests, A tests, RA pool product
This commit is contained in:
parent
d2cf70ce44
commit
3045f72304
|
|
@ -61,6 +61,7 @@ return [
|
||||||
'stripe' => [
|
'stripe' => [
|
||||||
'enabled' => env('SHOP_STRIPE_ENABLED', false),
|
'enabled' => env('SHOP_STRIPE_ENABLED', false),
|
||||||
'sync_prices' => true,
|
'sync_prices' => true,
|
||||||
|
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Cache configuration
|
// Cache configuration
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
# Stripe Checkout Integration
|
||||||
|
|
||||||
|
This document describes the Stripe Checkout integration for the Laravel Shop package.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Enable Stripe
|
||||||
|
|
||||||
|
Add the following to your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SHOP_STRIPE_ENABLED=true
|
||||||
|
STRIPE_KEY=pk_test_...
|
||||||
|
STRIPE_SECRET=sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure Services
|
||||||
|
|
||||||
|
In your `config/services.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'stripe' => [
|
||||||
|
'key' => env('STRIPE_KEY'),
|
||||||
|
'secret' => env('STRIPE_SECRET'),
|
||||||
|
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
## Price Configuration
|
||||||
|
|
||||||
|
All products and product prices must have a `stripe_price_id` before they can be used in Stripe Checkout.
|
||||||
|
|
||||||
|
### Setting Stripe Price ID
|
||||||
|
|
||||||
|
```php
|
||||||
|
$product = Product::find($id);
|
||||||
|
$price = $product->defaultPrice()->first();
|
||||||
|
$price->update(['stripe_price_id' => 'price_...']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a Checkout Session
|
||||||
|
|
||||||
|
### API Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/shop/stripe/checkout/{cartId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://your-domain.com/api/shop/stripe/checkout/cart-uuid-here \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "cs_test_...",
|
||||||
|
"url": "https://checkout.stripe.com/c/pay/cs_test_..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Redirect the user to the `url` to complete payment.
|
||||||
|
|
||||||
|
## Handling Success/Cancel
|
||||||
|
|
||||||
|
### Success URL
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/shop/stripe/success?session_id={SESSION_ID}&cart_id={CART_ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
When payment is successful:
|
||||||
|
- Cart status is updated to `CONVERTED`
|
||||||
|
- Cart's `converted_at` is set
|
||||||
|
- ProductPurchases are updated with:
|
||||||
|
- `status` → `COMPLETED`
|
||||||
|
- `charge_id` → Stripe Payment Intent ID
|
||||||
|
- `amount_paid` → Amount from Stripe (in dollars, converted from cents)
|
||||||
|
|
||||||
|
### Cancel URL
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/shop/stripe/cancel?cart_id={CART_ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
When payment is cancelled, the cart remains in `ACTIVE` status and the user can try again.
|
||||||
|
|
||||||
|
## Webhook Handler
|
||||||
|
|
||||||
|
### Webhook URL
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/shop/stripe/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported Events
|
||||||
|
|
||||||
|
The webhook handler processes the following Stripe events:
|
||||||
|
|
||||||
|
- `checkout.session.completed` - Updates cart to converted, updates purchases
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
### Configuring Webhook in Stripe
|
||||||
|
|
||||||
|
1. Go to Stripe Dashboard → Developers → Webhooks
|
||||||
|
2. Click "Add endpoint"
|
||||||
|
3. Enter your webhook URL: `https://your-domain.com/api/shop/stripe/webhook`
|
||||||
|
4. Select events to listen to (or select "receive all events")
|
||||||
|
5. Copy the signing secret and add it to your `.env` as `STRIPE_WEBHOOK_SECRET`
|
||||||
|
|
||||||
|
## Route Customization
|
||||||
|
|
||||||
|
### Disabling Stripe Routes
|
||||||
|
|
||||||
|
The Stripe routes are automatically registered if:
|
||||||
|
- `config('shop.stripe.enabled')` is `true`
|
||||||
|
- Routes haven't already been defined in your Laravel app
|
||||||
|
|
||||||
|
You can manually define routes in your application's route files to override the default behavior.
|
||||||
|
|
||||||
|
### Custom Routes Example
|
||||||
|
|
||||||
|
```php
|
||||||
|
// routes/web.php or routes/api.php
|
||||||
|
|
||||||
|
use Blax\Shop\Http\Controllers\StripeCheckoutController;
|
||||||
|
use Blax\Shop\Http\Controllers\StripeWebhookController;
|
||||||
|
|
||||||
|
Route::post('custom/stripe/checkout/{cartId}', [StripeCheckoutController::class, 'createCheckoutSession'])
|
||||||
|
->name('shop.stripe.checkout');
|
||||||
|
|
||||||
|
Route::get('custom/stripe/success', [StripeCheckoutController::class, 'success'])
|
||||||
|
->name('shop.stripe.success');
|
||||||
|
|
||||||
|
Route::get('custom/stripe/cancel', [StripeCheckoutController::class, 'cancel'])
|
||||||
|
->name('shop.stripe.cancel');
|
||||||
|
|
||||||
|
Route::post('custom/stripe/webhook', [StripeWebhookController::class, 'handleWebhook'])
|
||||||
|
->name('shop.stripe.webhook');
|
||||||
|
```
|
||||||
|
|
||||||
|
## ProductPurchase Updates
|
||||||
|
|
||||||
|
The webhook handler automatically updates ProductPurchase records with charge information if the columns exist:
|
||||||
|
|
||||||
|
- `charge_id` - Stripe Payment Intent ID
|
||||||
|
- `amount_paid` - Amount paid in dollars
|
||||||
|
|
||||||
|
These fields are automatically populated from the fillable array on the ProductPurchase model.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Missing Stripe Price ID
|
||||||
|
|
||||||
|
If a cart item doesn't have a `stripe_price_id`, the checkout session creation will fail with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Item 'Product Name' is missing a Stripe price ID"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stripe API Errors
|
||||||
|
|
||||||
|
All Stripe API errors are caught and logged. The response will include:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Failed to create checkout session: [error message]"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pool Products with MayBePoolProduct Trait
|
||||||
|
|
||||||
|
Pool-related methods have been moved to the `MayBePoolProduct` trait to keep the Product model cleaner.
|
||||||
|
|
||||||
|
### Using Pool Methods
|
||||||
|
|
||||||
|
All pool methods work the same way, they're just now in a trait:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$pool = Product::find($poolId);
|
||||||
|
|
||||||
|
// Check if pool
|
||||||
|
$pool->isPool(); // returns bool
|
||||||
|
|
||||||
|
// Get available quantity
|
||||||
|
$pool->getAvailableQuantity($from, $until);
|
||||||
|
|
||||||
|
// Get pool max quantity
|
||||||
|
$pool->getPoolMaxQuantity($from, $until);
|
||||||
|
|
||||||
|
// Claim pool stock
|
||||||
|
$pool->claimPoolStock($quantity, $reference, $from, $until);
|
||||||
|
|
||||||
|
// Release pool stock
|
||||||
|
$pool->releasePoolStock($reference);
|
||||||
|
|
||||||
|
// Pricing methods
|
||||||
|
$pool->getLowestPoolPrice();
|
||||||
|
$pool->getHighestPoolPrice();
|
||||||
|
$pool->getPoolPriceRange();
|
||||||
|
$pool->setPoolPricingStrategy('lowest'); // 'lowest', 'highest', 'average'
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
$pool->validatePoolConfiguration();
|
||||||
|
|
||||||
|
// Availability methods
|
||||||
|
$pool->getPoolAvailabilityCalendar($start, $end, $quantity);
|
||||||
|
$pool->getSingleItemsAvailability($from, $until);
|
||||||
|
$pool->isPoolAvailable($from, $until, $quantity);
|
||||||
|
$pool->getPoolAvailablePeriods($start, $end, $quantity, $minDays);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits of Trait
|
||||||
|
|
||||||
|
- Cleaner Product model
|
||||||
|
- Pool functionality can be used by other models in the future
|
||||||
|
- Better separation of concerns
|
||||||
|
- Easier testing and maintenance
|
||||||
|
|
||||||
|
## Example Usage Flow
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 1. Create a product with Stripe price
|
||||||
|
$product = Product::create([...]);
|
||||||
|
$price = ProductPrice::create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'stripe_price_id' => 'price_1234567890',
|
||||||
|
'unit_amount' => 2000, // $20.00 in cents
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. Add to cart
|
||||||
|
$cart = auth()->user()->currentCart();
|
||||||
|
$cart->addToCart($product, 1);
|
||||||
|
|
||||||
|
// 3. Create Stripe checkout session
|
||||||
|
$response = Http::post('/api/shop/stripe/checkout/' . $cart->id);
|
||||||
|
$checkoutUrl = $response->json('url');
|
||||||
|
|
||||||
|
// 4. Redirect user to Stripe
|
||||||
|
return redirect($checkoutUrl);
|
||||||
|
|
||||||
|
// 5. Stripe redirects back to success URL
|
||||||
|
// 6. Webhook processes payment
|
||||||
|
// 7. Cart is converted, purchases are completed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Mock Stripe in your tests:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Stripe\Stripe;
|
||||||
|
use Stripe\Checkout\Session as StripeSession;
|
||||||
|
|
||||||
|
// Mock Stripe session creation
|
||||||
|
Stripe::setApiKey('sk_test_fake');
|
||||||
|
StripeSession::create([...]); // Use test mode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Always verify webhook signatures** - Set `STRIPE_WEBHOOK_SECRET` in production
|
||||||
|
2. **Use HTTPS** - Stripe requires HTTPS for webhooks
|
||||||
|
3. **Validate cart ownership** - Ensure users can only checkout their own carts
|
||||||
|
4. **Test mode first** - Use Stripe test keys during development
|
||||||
|
5. **Monitor webhooks** - Check Stripe Dashboard for webhook delivery issues
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
use Blax\Shop\Http\Controllers\Api\CategoryController;
|
use Blax\Shop\Http\Controllers\Api\CategoryController;
|
||||||
use Blax\Shop\Http\Controllers\Api\ProductController;
|
use Blax\Shop\Http\Controllers\Api\ProductController;
|
||||||
|
use Blax\Shop\Http\Controllers\StripeCheckoutController;
|
||||||
|
use Blax\Shop\Http\Controllers\StripeWebhookController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
$config = config('shop.routes');
|
$config = config('shop.routes');
|
||||||
|
|
@ -31,3 +33,26 @@ Route::prefix($config['prefix'])
|
||||||
Route::get('products/{slug}', [ProductController::class, 'show'])
|
Route::get('products/{slug}', [ProductController::class, 'show'])
|
||||||
->name('products.show');
|
->name('products.show');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stripe routes - only if enabled and not already defined by the Laravel instance
|
||||||
|
if (config('shop.stripe.enabled', false) && !Route::has('shop.stripe.checkout')) {
|
||||||
|
Route::prefix($config['prefix'])
|
||||||
|
->middleware($config['middleware'])
|
||||||
|
->name($config['name_prefix'])
|
||||||
|
->group(function () {
|
||||||
|
// Stripe Checkout
|
||||||
|
Route::post('stripe/checkout/{cartId}', [StripeCheckoutController::class, 'createCheckoutSession'])
|
||||||
|
->name('stripe.checkout');
|
||||||
|
|
||||||
|
Route::get('stripe/success', [StripeCheckoutController::class, 'success'])
|
||||||
|
->name('stripe.success');
|
||||||
|
|
||||||
|
Route::get('stripe/cancel', [StripeCheckoutController::class, 'cancel'])
|
||||||
|
->name('stripe.cancel');
|
||||||
|
|
||||||
|
// Stripe Webhook (no auth middleware)
|
||||||
|
Route::post('stripe/webhook', [StripeWebhookController::class, 'handleWebhook'])
|
||||||
|
->withoutMiddleware($config['middleware'])
|
||||||
|
->name('stripe.webhook');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ enum ProductRelationType: string
|
||||||
case ADD_ON = 'add-on';
|
case ADD_ON = 'add-on';
|
||||||
case BUNDLE = 'bundle';
|
case BUNDLE = 'bundle';
|
||||||
case SINGLE = 'single';
|
case SINGLE = 'single';
|
||||||
|
case POOL = 'pool';
|
||||||
|
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
|
|
@ -25,6 +26,7 @@ enum ProductRelationType: string
|
||||||
self::ADD_ON => 'Add-on',
|
self::ADD_ON => 'Add-on',
|
||||||
self::BUNDLE => 'Bundle',
|
self::BUNDLE => 'Bundle',
|
||||||
self::SINGLE => 'Single',
|
self::SINGLE => 'Single',
|
||||||
|
self::POOL => 'Pool',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ enum PurchaseStatus: string
|
||||||
case COMPLETED = 'completed';
|
case COMPLETED = 'completed';
|
||||||
case REFUNDED = 'refunded';
|
case REFUNDED = 'refunded';
|
||||||
case CART = 'cart';
|
case CART = 'cart';
|
||||||
|
case FAILED = 'failed';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
|
|
@ -18,6 +19,7 @@ enum PurchaseStatus: string
|
||||||
self::COMPLETED => 'Completed',
|
self::COMPLETED => 'Completed',
|
||||||
self::REFUNDED => 'Refunded',
|
self::REFUNDED => 'Refunded',
|
||||||
self::CART => 'Cart',
|
self::CART => 'Cart',
|
||||||
|
self::FAILED => 'Failed',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Http\Controllers;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\Cart;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Stripe\Stripe;
|
||||||
|
use Stripe\Checkout\Session as StripeSession;
|
||||||
|
|
||||||
|
class StripeCheckoutController
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
if (config('shop.stripe.enabled')) {
|
||||||
|
Stripe::setApiKey(config('services.stripe.secret'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Checkout Session for a cart
|
||||||
|
*/
|
||||||
|
public function createCheckoutSession(Request $request, string $cartId)
|
||||||
|
{
|
||||||
|
if (!config('shop.stripe.enabled')) {
|
||||||
|
return response()->json(['error' => 'Stripe is not enabled'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cart = Cart::findOrFail($cartId);
|
||||||
|
|
||||||
|
// Use the cart's checkoutSession method which handles syncing and session creation
|
||||||
|
$session = $cart->checkoutSession([
|
||||||
|
'success_url' => $request->input('success_url'),
|
||||||
|
'cancel_url' => $request->input('cancel_url'),
|
||||||
|
'metadata' => $request->input('metadata', []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'url' => $session->url,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Stripe checkout session creation failed', [
|
||||||
|
'cart_id' => $cartId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Failed to create checkout session: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle successful payment
|
||||||
|
*/
|
||||||
|
public function success(Request $request)
|
||||||
|
{
|
||||||
|
$sessionId = $request->get('session_id');
|
||||||
|
$cartId = $request->get('cart_id');
|
||||||
|
|
||||||
|
if (!$sessionId || !$cartId) {
|
||||||
|
return response()->json(['error' => 'Missing session or cart ID'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cart = Cart::findOrFail($cartId);
|
||||||
|
|
||||||
|
// Verify the session
|
||||||
|
$session = StripeSession::retrieve($sessionId);
|
||||||
|
|
||||||
|
if ($session->payment_status === 'paid') {
|
||||||
|
// Update cart status to converted
|
||||||
|
$cart->update([
|
||||||
|
'status' => \Blax\Shop\Enums\CartStatus::CONVERTED,
|
||||||
|
'converted_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update product purchases with charge information
|
||||||
|
$purchases = $cart->items()->with('purchase')->get()->pluck('purchase')->filter();
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
if ($purchase && method_exists($purchase, 'update')) {
|
||||||
|
$updateData = [
|
||||||
|
'status' => \Blax\Shop\Enums\PurchaseStatus::COMPLETED,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only update if column exists
|
||||||
|
if (in_array('charge_id', $purchase->getFillable())) {
|
||||||
|
$updateData['charge_id'] = $session->payment_intent;
|
||||||
|
}
|
||||||
|
if (in_array('amount_paid', $purchase->getFillable())) {
|
||||||
|
$updateData['amount_paid'] = $session->amount_total / 100; // Convert from cents
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->update($updateData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Payment successful',
|
||||||
|
'cart_id' => $cart->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Payment not completed',
|
||||||
|
], 400);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Stripe success handler failed', [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'cart_id' => $cartId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Failed to process success: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancelled payment
|
||||||
|
*/
|
||||||
|
public function cancel(Request $request)
|
||||||
|
{
|
||||||
|
$cartId = $request->get('cart_id');
|
||||||
|
|
||||||
|
if (!$cartId) {
|
||||||
|
return response()->json(['error' => 'Missing cart ID'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cart = Cart::findOrFail($cartId);
|
||||||
|
|
||||||
|
// Cart remains in active state, user can try again
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Payment cancelled',
|
||||||
|
'cart_id' => $cart->id,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Stripe cancel handler failed', [
|
||||||
|
'cart_id' => $cartId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Failed to process cancellation: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Http\Controllers;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\Cart;
|
||||||
|
use Blax\Shop\Models\ProductPurchase;
|
||||||
|
use Blax\Shop\Enums\CartStatus;
|
||||||
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Stripe\Stripe;
|
||||||
|
use Stripe\Webhook;
|
||||||
|
use Stripe\Exception\SignatureVerificationException;
|
||||||
|
|
||||||
|
class StripeWebhookController
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
if (config('shop.stripe.enabled')) {
|
||||||
|
Stripe::setApiKey(config('services.stripe.secret'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Stripe webhook events
|
||||||
|
*/
|
||||||
|
public function handleWebhook(Request $request)
|
||||||
|
{
|
||||||
|
if (!config('shop.stripe.enabled')) {
|
||||||
|
return response()->json(['error' => 'Stripe is not enabled'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $request->getContent();
|
||||||
|
$sigHeader = $request->header('Stripe-Signature');
|
||||||
|
$webhookSecret = config('services.stripe.webhook_secret');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify webhook signature
|
||||||
|
if ($webhookSecret) {
|
||||||
|
$event = Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
|
||||||
|
} else {
|
||||||
|
// If no webhook secret, parse the event directly (not recommended for production)
|
||||||
|
$event = json_decode($payload);
|
||||||
|
}
|
||||||
|
} catch (\UnexpectedValueException $e) {
|
||||||
|
Log::error('Stripe webhook invalid payload', ['error' => $e->getMessage()]);
|
||||||
|
return response()->json(['error' => 'Invalid payload'], 400);
|
||||||
|
} catch (SignatureVerificationException $e) {
|
||||||
|
Log::error('Stripe webhook signature verification failed', ['error' => $e->getMessage()]);
|
||||||
|
return response()->json(['error' => 'Invalid signature'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the event
|
||||||
|
try {
|
||||||
|
switch ($event->type) {
|
||||||
|
case 'checkout.session.completed':
|
||||||
|
$this->handleCheckoutSessionCompleted($event->data->object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'checkout.session.async_payment_succeeded':
|
||||||
|
$this->handleCheckoutSessionCompleted($event->data->object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'checkout.session.async_payment_failed':
|
||||||
|
$this->handleCheckoutSessionFailed($event->data->object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'charge.succeeded':
|
||||||
|
$this->handleChargeSucceeded($event->data->object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'charge.failed':
|
||||||
|
$this->handleChargeFailed($event->data->object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'payment_intent.succeeded':
|
||||||
|
$this->handlePaymentIntentSucceeded($event->data->object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'payment_intent.payment_failed':
|
||||||
|
$this->handlePaymentIntentFailed($event->data->object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Log::info('Stripe webhook unhandled event type', ['type' => $event->type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Stripe webhook handler failed', [
|
||||||
|
'type' => $event->type,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['error' => 'Webhook handler failed'], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle checkout.session.completed event
|
||||||
|
*/
|
||||||
|
protected function handleCheckoutSessionCompleted($session)
|
||||||
|
{
|
||||||
|
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
|
||||||
|
|
||||||
|
if (!$cartId) {
|
||||||
|
Log::warning('Stripe checkout session completed without cart ID', ['session_id' => $session->id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cart = Cart::find($cartId);
|
||||||
|
if (!$cart) {
|
||||||
|
Log::warning('Stripe checkout session for non-existent cart', ['cart_id' => $cartId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if not already converted
|
||||||
|
if ($cart->status !== CartStatus::CONVERTED) {
|
||||||
|
$cart->update([
|
||||||
|
'status' => CartStatus::CONVERTED,
|
||||||
|
'converted_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update associated purchases
|
||||||
|
$this->updatePurchasesForSession($cart, $session);
|
||||||
|
|
||||||
|
Log::info('Cart converted via Stripe checkout', [
|
||||||
|
'cart_id' => $cart->id,
|
||||||
|
'session_id' => $session->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle checkout.session failed event
|
||||||
|
*/
|
||||||
|
protected function handleCheckoutSessionFailed($session)
|
||||||
|
{
|
||||||
|
$cartId = $session->metadata->cart_id ?? $session->client_reference_id;
|
||||||
|
|
||||||
|
if (!$cartId) {
|
||||||
|
Log::warning('Stripe checkout session failed without cart ID', ['session_id' => $session->id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Stripe checkout session failed', [
|
||||||
|
'cart_id' => $cartId,
|
||||||
|
'session_id' => $session->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cart remains in active state for retry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle charge.succeeded event
|
||||||
|
*/
|
||||||
|
protected function handleChargeSucceeded($charge)
|
||||||
|
{
|
||||||
|
Log::info('Stripe charge succeeded', [
|
||||||
|
'charge_id' => $charge->id,
|
||||||
|
'amount' => $charge->amount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update purchases with this charge ID if they exist
|
||||||
|
$purchases = ProductPurchase::where('charge_id', $charge->id)->get();
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
$updateData = [
|
||||||
|
'status' => PurchaseStatus::COMPLETED,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array('amount_paid', $purchase->getFillable())) {
|
||||||
|
$updateData['amount_paid'] = $charge->amount / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->update($updateData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle charge.failed event
|
||||||
|
*/
|
||||||
|
protected function handleChargeFailed($charge)
|
||||||
|
{
|
||||||
|
Log::warning('Stripe charge failed', [
|
||||||
|
'charge_id' => $charge->id,
|
||||||
|
'failure_message' => $charge->failure_message ?? 'Unknown error',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update purchases with this charge ID
|
||||||
|
$purchases = ProductPurchase::where('charge_id', $charge->id)->get();
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
$purchase->update([
|
||||||
|
'status' => PurchaseStatus::FAILED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payment_intent.succeeded event
|
||||||
|
*/
|
||||||
|
protected function handlePaymentIntentSucceeded($paymentIntent)
|
||||||
|
{
|
||||||
|
Log::info('Stripe payment intent succeeded', [
|
||||||
|
'payment_intent_id' => $paymentIntent->id,
|
||||||
|
'amount' => $paymentIntent->amount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update purchases with this payment intent
|
||||||
|
$purchases = ProductPurchase::where('charge_id', $paymentIntent->id)->get();
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
$updateData = [
|
||||||
|
'status' => PurchaseStatus::COMPLETED,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array('amount_paid', $purchase->getFillable())) {
|
||||||
|
$updateData['amount_paid'] = $paymentIntent->amount / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->update($updateData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payment_intent.payment_failed event
|
||||||
|
*/
|
||||||
|
protected function handlePaymentIntentFailed($paymentIntent)
|
||||||
|
{
|
||||||
|
Log::warning('Stripe payment intent failed', [
|
||||||
|
'payment_intent_id' => $paymentIntent->id,
|
||||||
|
'last_payment_error' => $paymentIntent->last_payment_error->message ?? 'Unknown error',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$purchases = ProductPurchase::where('charge_id', $paymentIntent->id)->get();
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
$purchase->update([
|
||||||
|
'status' => PurchaseStatus::FAILED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update product purchases for a checkout session
|
||||||
|
*/
|
||||||
|
protected function updatePurchasesForSession(Cart $cart, $session)
|
||||||
|
{
|
||||||
|
$purchases = $cart->items()->with('purchase')->get()->pluck('purchase')->filter();
|
||||||
|
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
if (!$purchase) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updateData = [
|
||||||
|
'status' => PurchaseStatus::COMPLETED,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only update columns that exist in the model
|
||||||
|
if (in_array('charge_id', $purchase->getFillable())) {
|
||||||
|
$updateData['charge_id'] = $session->payment_intent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('amount_paid', $purchase->getFillable())) {
|
||||||
|
$updateData['amount_paid'] = $session->amount_total / 100; // Convert from cents
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->update($updateData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ use Blax\Shop\Contracts\Cartable;
|
||||||
use Blax\Shop\Enums\CartStatus;
|
use Blax\Shop\Enums\CartStatus;
|
||||||
use Blax\Shop\Enums\ProductType;
|
use Blax\Shop\Enums\ProductType;
|
||||||
use Blax\Workkit\Traits\HasExpiration;
|
use Blax\Workkit\Traits\HasExpiration;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -200,6 +201,14 @@ class Cart extends Model
|
||||||
throw new \Exception("Item must implement the Cartable interface.");
|
throw new \Exception("Item must implement the Cartable interface.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract dates from parameters if not provided directly
|
||||||
|
if (!$from && isset($parameters['from'])) {
|
||||||
|
$from = is_string($parameters['from']) ? Carbon::parse($parameters['from']) : $parameters['from'];
|
||||||
|
}
|
||||||
|
if (!$until && isset($parameters['until'])) {
|
||||||
|
$until = is_string($parameters['until']) ? Carbon::parse($parameters['until']) : $parameters['until'];
|
||||||
|
}
|
||||||
|
|
||||||
// Validate Product-specific requirements
|
// Validate Product-specific requirements
|
||||||
if ($cartable instanceof Product) {
|
if ($cartable instanceof Product) {
|
||||||
// Validate pricing before adding to cart
|
// Validate pricing before adding to cart
|
||||||
|
|
@ -222,7 +231,8 @@ class Cart extends Model
|
||||||
// Check pool product availability if dates are provided
|
// Check pool product availability if dates are provided
|
||||||
if ($cartable->isPool()) {
|
if ($cartable->isPool()) {
|
||||||
$maxQuantity = $cartable->getPoolMaxQuantity($from, $until);
|
$maxQuantity = $cartable->getPoolMaxQuantity($from, $until);
|
||||||
if ($quantity > $maxQuantity) {
|
// Only validate if pool has limited availability
|
||||||
|
if ($maxQuantity !== PHP_INT_MAX && $quantity > $maxQuantity) {
|
||||||
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
|
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
|
||||||
"Pool product '{$cartable->name}' has only {$maxQuantity} items available for the requested period ({$from->format('Y-m-d')} to {$until->format('Y-m-d')}). Requested: {$quantity}"
|
"Pool product '{$cartable->name}' has only {$maxQuantity} items available for the requested period ({$from->format('Y-m-d')} to {$until->format('Y-m-d')}). Requested: {$quantity}"
|
||||||
);
|
);
|
||||||
|
|
@ -231,15 +241,37 @@ class Cart extends Model
|
||||||
} elseif ($from || $until) {
|
} elseif ($from || $until) {
|
||||||
// If only one date is provided, it's an error
|
// If only one date is provided, it's an error
|
||||||
throw new \Exception("Both 'from' and 'until' dates must be provided together, or both omitted.");
|
throw new \Exception("Both 'from' and 'until' dates must be provided together, or both omitted.");
|
||||||
|
} else {
|
||||||
|
// Even without dates, check pool quantity limits
|
||||||
|
if ($cartable->isPool()) {
|
||||||
|
$maxQuantity = $cartable->getPoolMaxQuantity();
|
||||||
|
|
||||||
|
// Skip validation if pool has unlimited availability
|
||||||
|
if ($maxQuantity !== PHP_INT_MAX) {
|
||||||
|
// Get current quantity in cart for this pool product
|
||||||
|
$currentQuantityInCart = $this->items()
|
||||||
|
->where('purchasable_id', $cartable->getKey())
|
||||||
|
->where('purchasable_type', get_class($cartable))
|
||||||
|
->sum('quantity');
|
||||||
|
|
||||||
|
$totalQuantity = $currentQuantityInCart + $quantity;
|
||||||
|
|
||||||
|
if ($totalQuantity > $maxQuantity) {
|
||||||
|
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
|
||||||
|
"Pool product '{$cartable->name}' has only {$maxQuantity} items available. Already in cart: {$currentQuantityInCart}, Requested: {$quantity}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if item already exists in cart with same parameters and dates
|
// Check if item already exists in cart with same parameters, dates, AND price
|
||||||
$existingItem = $this->items()
|
$existingItem = $this->items()
|
||||||
->where('purchasable_id', $cartable->getKey())
|
->where('purchasable_id', $cartable->getKey())
|
||||||
->where('purchasable_type', get_class($cartable))
|
->where('purchasable_type', get_class($cartable))
|
||||||
->get()
|
->get()
|
||||||
->first(function ($item) use ($parameters, $from, $until) {
|
->first(function ($item) use ($parameters, $from, $until, $cartable) {
|
||||||
$existingParams = is_array($item->parameters)
|
$existingParams = is_array($item->parameters)
|
||||||
? $item->parameters
|
? $item->parameters
|
||||||
: (array) $item->parameters;
|
: (array) $item->parameters;
|
||||||
|
|
@ -260,7 +292,20 @@ class Cart extends Model
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $paramsMatch && $datesMatch;
|
// For pool products, also check if price matches
|
||||||
|
// Different prices mean different availability/items, so separate cart items
|
||||||
|
$priceMatch = true;
|
||||||
|
if ($cartable instanceof Product && $cartable->isPool()) {
|
||||||
|
$currentPrice = $cartable->getCurrentPrice();
|
||||||
|
if ($from && $until) {
|
||||||
|
$days = max(1, $from->diff($until)->days);
|
||||||
|
$currentPrice *= $days;
|
||||||
|
}
|
||||||
|
// Compare with small delta to account for floating point precision
|
||||||
|
$priceMatch = abs((float)$item->price - $currentPrice) < 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paramsMatch && $datesMatch && $priceMatch;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate price per day (base price)
|
// Calculate price per day (base price)
|
||||||
|
|
@ -347,7 +392,12 @@ class Cart extends Model
|
||||||
return $item ?? true;
|
return $item ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function checkout(): static
|
/**
|
||||||
|
* Validate cart for checkout without converting it
|
||||||
|
*
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function validateForCheckout(): void
|
||||||
{
|
{
|
||||||
$items = $this->items()
|
$items = $this->items()
|
||||||
->with('purchasable')
|
->with('purchasable')
|
||||||
|
|
@ -357,6 +407,27 @@ class Cart extends Model
|
||||||
throw new \Exception("Cart is empty");
|
throw new \Exception("Cart is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that all items have required information before checkout
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$adjustments = $item->requiredAdjustments();
|
||||||
|
if (!empty($adjustments)) {
|
||||||
|
$product = $item->purchasable;
|
||||||
|
$productName = $product ? $product->name : 'Unknown Product';
|
||||||
|
$missingFields = implode(', ', array_keys($adjustments));
|
||||||
|
throw new \Exception("Cart item '{$productName}' is missing required information: {$missingFields}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkout(): static
|
||||||
|
{
|
||||||
|
// Validate cart before proceeding
|
||||||
|
$this->validateForCheckout();
|
||||||
|
|
||||||
|
$items = $this->items()
|
||||||
|
->with('purchasable')
|
||||||
|
->get();
|
||||||
|
|
||||||
// Create ProductPurchase for each cart item
|
// Create ProductPurchase for each cart item
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$product = $item->purchasable;
|
$product = $item->purchasable;
|
||||||
|
|
@ -438,4 +509,130 @@ class Cart extends Model
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Checkout Session for this cart
|
||||||
|
*
|
||||||
|
* This method:
|
||||||
|
* - Validates the cart (doesn't convert it)
|
||||||
|
* - Syncs products/prices to Stripe (creates them if they don't exist)
|
||||||
|
* - Creates line items with descriptions including booking dates
|
||||||
|
* - Returns the Stripe checkout session
|
||||||
|
*
|
||||||
|
* @param array $options Optional session parameters (success_url, cancel_url, etc.)
|
||||||
|
* @return \Stripe\Checkout\Session
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function checkoutSession(array $options = []): \Stripe\Checkout\Session
|
||||||
|
{
|
||||||
|
if (!config('shop.stripe.enabled')) {
|
||||||
|
throw new \Exception('Stripe is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Stripe is initialized
|
||||||
|
\Stripe\Stripe::setApiKey(config('services.stripe.secret'));
|
||||||
|
|
||||||
|
// Validate cart before proceeding (doesn't convert it)
|
||||||
|
$this->validateForCheckout();
|
||||||
|
|
||||||
|
$syncService = new \Blax\Shop\Services\StripeSyncService();
|
||||||
|
$lineItems = [];
|
||||||
|
|
||||||
|
foreach ($this->items as $item) {
|
||||||
|
$purchasable = $item->purchasable;
|
||||||
|
|
||||||
|
// Get the price model
|
||||||
|
if ($purchasable instanceof Product) {
|
||||||
|
$price = $purchasable->defaultPrice()->first();
|
||||||
|
$product = $purchasable;
|
||||||
|
} elseif ($purchasable instanceof \Blax\Shop\Models\ProductPrice) {
|
||||||
|
$price = $purchasable;
|
||||||
|
$product = $purchasable->purchasable;
|
||||||
|
} else {
|
||||||
|
throw new \Exception("Item has no valid price");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$price) {
|
||||||
|
$name = $purchasable->name ?? 'Unknown item';
|
||||||
|
throw new \Exception("Item '{$name}' has no default price");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync product and price to Stripe
|
||||||
|
$stripePriceId = $syncService->syncPrice($price, $product);
|
||||||
|
|
||||||
|
// Build line item with description including booking dates if applicable
|
||||||
|
$lineItem = [
|
||||||
|
'price' => $stripePriceId,
|
||||||
|
'quantity' => $item->quantity,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add description with booking dates if available
|
||||||
|
$description = null;
|
||||||
|
if ($item->from && $item->until) {
|
||||||
|
$days = max(1, $item->from->diffInDays($item->until));
|
||||||
|
$fromFormatted = $item->from->format('M j, Y');
|
||||||
|
$untilFormatted = $item->until->format('M j, Y');
|
||||||
|
$description = "Period: {$fromFormatted} to {$untilFormatted} ({$days} day" . ($days > 1 ? 's' : '') . ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($description) {
|
||||||
|
$lineItem['description'] = $description;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lineItems[] = $lineItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare session parameters
|
||||||
|
$sessionParams = [
|
||||||
|
'payment_method_types' => ['card'],
|
||||||
|
'line_items' => $lineItems,
|
||||||
|
'mode' => 'payment',
|
||||||
|
'success_url' => $options['success_url'] ?? route('shop.stripe.success') . '?session_id={CHECKOUT_SESSION_ID}&cart_id=' . $this->id,
|
||||||
|
'cancel_url' => $options['cancel_url'] ?? route('shop.stripe.cancel') . '?cart_id=' . $this->id,
|
||||||
|
'client_reference_id' => $this->id,
|
||||||
|
'metadata' => array_merge([
|
||||||
|
'cart_id' => $this->id,
|
||||||
|
], $options['metadata'] ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add customer email if available
|
||||||
|
if ($this->customer) {
|
||||||
|
if (method_exists($this->customer, 'email')) {
|
||||||
|
$sessionParams['customer_email'] = $this->customer->email;
|
||||||
|
} elseif (isset($this->customer->email)) {
|
||||||
|
$sessionParams['customer_email'] = $this->customer->email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow custom session parameters
|
||||||
|
if (isset($options['session_params'])) {
|
||||||
|
$sessionParams = array_merge($sessionParams, $options['session_params']);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$session = \Stripe\Checkout\Session::create($sessionParams);
|
||||||
|
|
||||||
|
// Store session ID in cart meta
|
||||||
|
$meta = $this->meta ?? (object)[];
|
||||||
|
if (is_array($meta)) {
|
||||||
|
$meta = (object)$meta;
|
||||||
|
}
|
||||||
|
$meta->stripe_session_id = $session->id;
|
||||||
|
$this->update(['meta' => $meta]);
|
||||||
|
|
||||||
|
\Illuminate\Support\Facades\Log::info('Stripe checkout session created', [
|
||||||
|
'cart_id' => $this->id,
|
||||||
|
'session_id' => $session->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::error('Stripe checkout session creation failed', [
|
||||||
|
'cart_id' => $this->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -168,4 +168,91 @@ class CartItem extends Model
|
||||||
|
|
||||||
return $adjustments;
|
return $adjustments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the booking dates for this cart item.
|
||||||
|
* Automatically recalculates price based on the new date range.
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface $from Start date
|
||||||
|
* @param \DateTimeInterface $until End date
|
||||||
|
* @return $this
|
||||||
|
* @throws \Exception If dates are invalid
|
||||||
|
*/
|
||||||
|
public function updateDates(\DateTimeInterface $from, \DateTimeInterface $until): self
|
||||||
|
{
|
||||||
|
if ($from >= $until) {
|
||||||
|
throw new \Exception("The 'from' date must be before the 'until' date.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$product = $this->purchasable;
|
||||||
|
|
||||||
|
if (!$product || !($product instanceof Product)) {
|
||||||
|
throw new \Exception("Cannot update dates for non-product items.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate days
|
||||||
|
$days = max(1, $from->diff($until)->days);
|
||||||
|
|
||||||
|
// Get current price per day
|
||||||
|
$pricePerDay = $product->getCurrentPrice();
|
||||||
|
$regularPricePerDay = $product->getCurrentPrice(false) ?? $pricePerDay;
|
||||||
|
|
||||||
|
// Calculate new prices
|
||||||
|
$pricePerUnit = $pricePerDay * $days;
|
||||||
|
$regularPricePerUnit = $regularPricePerDay * $days;
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'from' => $from,
|
||||||
|
'until' => $until,
|
||||||
|
'price' => $pricePerUnit,
|
||||||
|
'regular_price' => $regularPricePerUnit,
|
||||||
|
'subtotal' => $pricePerUnit * $this->quantity,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the 'from' date for this cart item.
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface $from Start date
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setFromDate(\DateTimeInterface $from): self
|
||||||
|
{
|
||||||
|
if ($this->until && $from >= $this->until) {
|
||||||
|
throw new \Exception("The 'from' date must be before the 'until' date.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->update(['from' => $from]);
|
||||||
|
|
||||||
|
// If both dates are now set, recalculate pricing
|
||||||
|
if ($this->until) {
|
||||||
|
return $this->updateDates($from, $this->until);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the 'until' date for this cart item.
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface $until End date
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setUntilDate(\DateTimeInterface $until): self
|
||||||
|
{
|
||||||
|
if ($this->from && $this->from >= $until) {
|
||||||
|
throw new \Exception("The 'until' date must be after the 'from' date.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->update(['until' => $until]);
|
||||||
|
|
||||||
|
// If both dates are now set, recalculate pricing
|
||||||
|
if ($this->from) {
|
||||||
|
return $this->updateDates($this->from, $until);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use Blax\Shop\Events\ProductUpdated;
|
||||||
use Blax\Shop\Contracts\Purchasable;
|
use Blax\Shop\Contracts\Purchasable;
|
||||||
use Blax\Shop\Enums\ProductStatus;
|
use Blax\Shop\Enums\ProductStatus;
|
||||||
use Blax\Shop\Enums\ProductType;
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Enums\ProductRelationType;
|
||||||
use Blax\Shop\Enums\StockStatus;
|
use Blax\Shop\Enums\StockStatus;
|
||||||
use Blax\Shop\Enums\StockType;
|
use Blax\Shop\Enums\StockType;
|
||||||
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
|
use Blax\Shop\Exceptions\HasNoDefaultPriceException;
|
||||||
|
|
@ -19,6 +20,7 @@ use Blax\Shop\Traits\HasCategories;
|
||||||
use Blax\Shop\Traits\HasPrices;
|
use Blax\Shop\Traits\HasPrices;
|
||||||
use Blax\Shop\Traits\HasProductRelations;
|
use Blax\Shop\Traits\HasProductRelations;
|
||||||
use Blax\Shop\Traits\HasStocks;
|
use Blax\Shop\Traits\HasStocks;
|
||||||
|
use Blax\Shop\Traits\MayBePoolProduct;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -28,7 +30,7 @@ use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class Product extends Model implements Purchasable, Cartable
|
class Product extends Model implements Purchasable, Cartable
|
||||||
{
|
{
|
||||||
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories, HasProductRelations;
|
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories, HasProductRelations, MayBePoolProduct;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'slug',
|
'slug',
|
||||||
|
|
@ -316,148 +318,6 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
return $this->type === ProductType::BOOKING;
|
return $this->type === ProductType::BOOKING;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this is a pool product
|
|
||||||
*/
|
|
||||||
public function isPool(): bool
|
|
||||||
{
|
|
||||||
return $this->type === ProductType::POOL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the maximum available quantity for a pool product based on single items
|
|
||||||
*/
|
|
||||||
public function getPoolMaxQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int
|
|
||||||
{
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
return $this->getAvailableStock();
|
|
||||||
}
|
|
||||||
|
|
||||||
$singleItems = $this->singleProducts;
|
|
||||||
|
|
||||||
if ($singleItems->isEmpty()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no dates provided, return the count of single items
|
|
||||||
if (!$from || !$until) {
|
|
||||||
return $singleItems->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check availability for each single item during the timespan
|
|
||||||
$availableCount = 0;
|
|
||||||
foreach ($singleItems as $item) {
|
|
||||||
if ($item->isAvailableForBooking($from, $until, 1)) {
|
|
||||||
$availableCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $availableCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim stock for a pool product
|
|
||||||
* This will claim stock from the available single items
|
|
||||||
*
|
|
||||||
* @param int $quantity Number of pool items to claim
|
|
||||||
* @param mixed $reference Reference model
|
|
||||||
* @param \DateTimeInterface|null $from Start date
|
|
||||||
* @param \DateTimeInterface|null $until End date
|
|
||||||
* @param string|null $note Optional note
|
|
||||||
* @return array Array of claimed single item products
|
|
||||||
* @throws \Exception
|
|
||||||
*/
|
|
||||||
public function claimPoolStock(
|
|
||||||
int $quantity,
|
|
||||||
$reference = null,
|
|
||||||
?\DateTimeInterface $from = null,
|
|
||||||
?\DateTimeInterface $until = null,
|
|
||||||
?string $note = null
|
|
||||||
): array {
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
throw new \Exception('This method is only for pool products');
|
|
||||||
}
|
|
||||||
|
|
||||||
$singleItems = $this->singleProducts;
|
|
||||||
|
|
||||||
if ($singleItems->isEmpty()) {
|
|
||||||
throw new \Exception('Pool product has no single items to claim');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get available single items for the period
|
|
||||||
$availableItems = [];
|
|
||||||
foreach ($singleItems as $item) {
|
|
||||||
if ($item->isAvailableForBooking($from, $until, 1)) {
|
|
||||||
$availableItems[] = $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($availableItems) >= $quantity) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($availableItems) < $quantity) {
|
|
||||||
throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claim stock from each selected single item
|
|
||||||
$claimedItems = [];
|
|
||||||
foreach (array_slice($availableItems, 0, $quantity) as $item) {
|
|
||||||
$item->claimStock(1, $reference, $from, $until, $note);
|
|
||||||
$claimedItems[] = $item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $claimedItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release pool stock claims
|
|
||||||
*
|
|
||||||
* @param mixed $reference Reference model used when claiming
|
|
||||||
* @return int Number of claims released
|
|
||||||
*/
|
|
||||||
public function releasePoolStock($reference): int
|
|
||||||
{
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
throw new \Exception('This method is only for pool products');
|
|
||||||
}
|
|
||||||
|
|
||||||
$singleItems = $this->singleProducts;
|
|
||||||
$released = 0;
|
|
||||||
|
|
||||||
foreach ($singleItems as $item) {
|
|
||||||
$referenceType = is_object($reference) ? get_class($reference) : null;
|
|
||||||
$referenceId = is_object($reference) ? $reference->id : null;
|
|
||||||
|
|
||||||
// Find and delete claims for this reference
|
|
||||||
$claims = $item->stocks()
|
|
||||||
->where('type', StockType::CLAIMED->value)
|
|
||||||
->where('status', StockStatus::PENDING->value)
|
|
||||||
->where('reference_type', $referenceType)
|
|
||||||
->where('reference_id', $referenceId)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($claims as $claim) {
|
|
||||||
$claim->release();
|
|
||||||
$released++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $released;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if any single item in pool is a booking product
|
|
||||||
*/
|
|
||||||
public function hasBookingSingleItems(): bool
|
|
||||||
{
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->singleProducts()->where('products.type', ProductType::BOOKING->value)->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check stock availability for a booking period
|
* Check stock availability for a booking period
|
||||||
*/
|
*/
|
||||||
|
|
@ -511,205 +371,15 @@ class Product extends Model implements Purchasable, Cartable
|
||||||
*/
|
*/
|
||||||
public function getCurrentPrice(bool|null $sales_price = null): ?float
|
public function getCurrentPrice(bool|null $sales_price = null): ?float
|
||||||
{
|
{
|
||||||
// If this is a pool product and it has no direct price, inherit from single items
|
// If this is a pool product, use the trait method
|
||||||
if ($this->isPool() && !$this->hasPrice()) {
|
if ($this->isPool()) {
|
||||||
return $this->getInheritedPoolPrice($sales_price);
|
return $this->getPoolCurrentPrice($sales_price);
|
||||||
}
|
|
||||||
|
|
||||||
// If pool has a direct price, use it
|
|
||||||
if ($this->isPool() && $this->hasPrice()) {
|
|
||||||
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-pool products, use the trait's default behavior
|
// For non-pool products, use the trait's default behavior
|
||||||
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get inherited price from single items based on pricing strategy
|
|
||||||
*/
|
|
||||||
protected function getInheritedPoolPrice(bool|null $sales_price = null): ?float
|
|
||||||
{
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$strategy = $this->getPoolPricingStrategy();
|
|
||||||
|
|
||||||
$singleItems = $this->singleProducts;
|
|
||||||
|
|
||||||
if ($singleItems->isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prices = $singleItems->map(function ($item) use ($sales_price) {
|
|
||||||
return $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale());
|
|
||||||
})->filter()->values();
|
|
||||||
|
|
||||||
if ($prices->isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($strategy) {
|
|
||||||
'lowest' => $prices->min(),
|
|
||||||
'highest' => $prices->max(),
|
|
||||||
'average' => round($prices->avg()),
|
|
||||||
default => round($prices->avg()), // Default to average
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the pool pricing strategy from metadata
|
|
||||||
*/
|
|
||||||
public function getPoolPricingStrategy(): string
|
|
||||||
{
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
return 'average';
|
|
||||||
}
|
|
||||||
|
|
||||||
$meta = $this->getMeta();
|
|
||||||
return $meta->pricing_strategy ?? 'average';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the pool pricing strategy
|
|
||||||
*/
|
|
||||||
public function setPoolPricingStrategy(string $strategy): void
|
|
||||||
{
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
throw new \Exception('This method is only for pool products');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array($strategy, ['average', 'lowest', 'highest'])) {
|
|
||||||
throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->updateMetaKey('pricing_strategy', $strategy);
|
|
||||||
$this->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the lowest price from single items
|
|
||||||
*/
|
|
||||||
public function getLowestPoolPrice(): ?float
|
|
||||||
{
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$singleItems = $this->singleProducts;
|
|
||||||
|
|
||||||
if ($singleItems->isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prices = $singleItems->map(function ($item) {
|
|
||||||
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
|
||||||
})->filter()->values();
|
|
||||||
|
|
||||||
return $prices->isEmpty() ? null : $prices->min();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the highest price from single items
|
|
||||||
*/
|
|
||||||
public function getHighestPoolPrice(): ?float
|
|
||||||
{
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$singleItems = $this->singleProducts;
|
|
||||||
|
|
||||||
if ($singleItems->isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prices = $singleItems->map(function ($item) {
|
|
||||||
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
|
||||||
})->filter()->values();
|
|
||||||
|
|
||||||
return $prices->isEmpty() ? null : $prices->max();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the price range for pool products
|
|
||||||
*/
|
|
||||||
public function getPoolPriceRange(): ?array
|
|
||||||
{
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lowest = $this->getLowestPoolPrice();
|
|
||||||
$highest = $this->getHighestPoolPrice();
|
|
||||||
|
|
||||||
if ($lowest === null || $highest === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'min' => $lowest,
|
|
||||||
'max' => $highest,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate pool product configuration and provide helpful error messages
|
|
||||||
*
|
|
||||||
* @throws InvalidPoolConfigurationException
|
|
||||||
*/
|
|
||||||
public function validatePoolConfiguration(bool $throwOnWarnings = false): array
|
|
||||||
{
|
|
||||||
$errors = [];
|
|
||||||
$warnings = [];
|
|
||||||
|
|
||||||
if (!$this->isPool()) {
|
|
||||||
throw InvalidPoolConfigurationException::notAPoolProduct($this->name);
|
|
||||||
}
|
|
||||||
|
|
||||||
$singleItems = $this->singleProducts;
|
|
||||||
|
|
||||||
// Critical: No single items
|
|
||||||
if ($singleItems->isEmpty()) {
|
|
||||||
throw InvalidPoolConfigurationException::noSingleItems($this->name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for mixed product types
|
|
||||||
$types = $singleItems->pluck('type')->unique();
|
|
||||||
if ($types->count() > 1) {
|
|
||||||
$warning = "Mixed single item types detected. This may cause unexpected behavior.";
|
|
||||||
$warnings[] = $warning;
|
|
||||||
if ($throwOnWarnings) {
|
|
||||||
throw InvalidPoolConfigurationException::mixedSingleItemTypes($this->name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check stock management on single items
|
|
||||||
$itemsWithoutStock = $singleItems->filter(fn($item) => !$item->manage_stock);
|
|
||||||
if ($itemsWithoutStock->isNotEmpty()) {
|
|
||||||
$itemNames = $itemsWithoutStock->pluck('name')->toArray();
|
|
||||||
$errors[] = "Single items without stock management: " . implode(', ', $itemNames);
|
|
||||||
throw InvalidPoolConfigurationException::singleItemsWithoutStock($this->name, $itemNames);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for items with zero stock
|
|
||||||
$itemsWithZeroStock = $singleItems->filter(fn($item) => $item->getAvailableStock() <= 0);
|
|
||||||
if ($itemsWithZeroStock->isNotEmpty()) {
|
|
||||||
$itemNames = $itemsWithZeroStock->pluck('name')->toArray();
|
|
||||||
$warnings[] = "Single items with zero stock: " . implode(', ', $itemNames);
|
|
||||||
if ($throwOnWarnings) {
|
|
||||||
throw InvalidPoolConfigurationException::singleItemsWithZeroStock($this->name, $itemNames);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'valid' => empty($errors),
|
|
||||||
'errors' => $errors,
|
|
||||||
'warnings' => $warnings,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate booking product configuration and provide helpful error messages
|
* Validate booking product configuration and provide helpful error messages
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Services;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Stripe\Stripe;
|
||||||
|
use Stripe\Product as StripeProduct;
|
||||||
|
use Stripe\Price as StripePrice;
|
||||||
|
|
||||||
|
class StripeSyncService
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
if (config('shop.stripe.enabled')) {
|
||||||
|
Stripe::setApiKey(config('services.stripe.secret'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a product to Stripe and return the Stripe product ID
|
||||||
|
*
|
||||||
|
* @param Product $product
|
||||||
|
* @return string Stripe Product ID
|
||||||
|
*/
|
||||||
|
public function syncProduct(Product $product): string
|
||||||
|
{
|
||||||
|
if (!config('shop.stripe.enabled')) {
|
||||||
|
throw new \Exception('Stripe is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if product already has a Stripe ID
|
||||||
|
if ($product->stripe_product_id) {
|
||||||
|
try {
|
||||||
|
// Verify the product still exists in Stripe
|
||||||
|
StripeProduct::retrieve($product->stripe_product_id);
|
||||||
|
|
||||||
|
// Update the product in Stripe
|
||||||
|
StripeProduct::update($product->stripe_product_id, [
|
||||||
|
'name' => $product->name,
|
||||||
|
'description' => $product->short_description ?? $product->description,
|
||||||
|
'active' => $product->status === \Blax\Shop\Enums\ProductStatus::PUBLISHED,
|
||||||
|
'metadata' => [
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'sku' => $product->sku,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $product->stripe_product_id;
|
||||||
|
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||||
|
// Product doesn't exist in Stripe, create a new one
|
||||||
|
Log::warning('Stripe product not found, creating new one', [
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'stripe_product_id' => $product->stripe_product_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new Stripe product
|
||||||
|
$stripeProduct = StripeProduct::create([
|
||||||
|
'name' => $product->name,
|
||||||
|
'description' => $product->short_description ?? $product->description,
|
||||||
|
'active' => $product->status === \Blax\Shop\Enums\ProductStatus::PUBLISHED,
|
||||||
|
'metadata' => [
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'sku' => $product->sku,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update local product with Stripe ID
|
||||||
|
$product->update(['stripe_product_id' => $stripeProduct->id]);
|
||||||
|
|
||||||
|
Log::info('Product synced to Stripe', [
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'stripe_product_id' => $stripeProduct->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $stripeProduct->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a product price to Stripe and return the Stripe price ID
|
||||||
|
*
|
||||||
|
* @param ProductPrice $price
|
||||||
|
* @param Product|null $product
|
||||||
|
* @return string Stripe Price ID
|
||||||
|
*/
|
||||||
|
public function syncPrice(ProductPrice $price, ?Product $product = null): string
|
||||||
|
{
|
||||||
|
if (!config('shop.stripe.enabled')) {
|
||||||
|
throw new \Exception('Stripe is not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the product if not provided
|
||||||
|
if (!$product && $price->purchasable instanceof Product) {
|
||||||
|
$product = $price->purchasable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$product) {
|
||||||
|
throw new \Exception('Cannot sync price without associated product');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure product is synced to Stripe
|
||||||
|
$stripeProductId = $this->syncProduct($product);
|
||||||
|
|
||||||
|
// Check if price already has a Stripe ID
|
||||||
|
if ($price->stripe_price_id) {
|
||||||
|
try {
|
||||||
|
// Verify the price still exists in Stripe
|
||||||
|
$stripePrice = StripePrice::retrieve($price->stripe_price_id);
|
||||||
|
|
||||||
|
// Check if price parameters match
|
||||||
|
$unitAmount = (int) ($price->unit_amount * 100); // Convert to cents
|
||||||
|
|
||||||
|
if (
|
||||||
|
$stripePrice->unit_amount === $unitAmount &&
|
||||||
|
$stripePrice->currency === strtolower($price->currency)
|
||||||
|
) {
|
||||||
|
return $price->stripe_price_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price parameters changed, need to create a new price
|
||||||
|
// (Stripe prices are immutable)
|
||||||
|
Log::info('Price parameters changed, creating new Stripe price', [
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'old_stripe_price_id' => $price->stripe_price_id,
|
||||||
|
]);
|
||||||
|
} catch (\Stripe\Exception\InvalidRequestException $e) {
|
||||||
|
// Price doesn't exist in Stripe, create a new one
|
||||||
|
Log::warning('Stripe price not found, creating new one', [
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'stripe_price_id' => $price->stripe_price_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new Stripe price
|
||||||
|
$unitAmount = (int) ($price->unit_amount * 100); // Convert to cents
|
||||||
|
|
||||||
|
$priceParams = [
|
||||||
|
'product' => $stripeProductId,
|
||||||
|
'unit_amount' => $unitAmount,
|
||||||
|
'currency' => strtolower($price->currency),
|
||||||
|
'metadata' => [
|
||||||
|
'price_id' => $price->id,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add recurring parameters if applicable
|
||||||
|
if ($price->type === \Blax\Shop\Enums\PriceType::RECURRING) {
|
||||||
|
$priceParams['recurring'] = [
|
||||||
|
'interval' => $price->interval->value,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($price->interval_count && $price->interval_count > 1) {
|
||||||
|
$priceParams['recurring']['interval_count'] = $price->interval_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripePrice = StripePrice::create($priceParams);
|
||||||
|
|
||||||
|
// Update local price with Stripe ID
|
||||||
|
$price->update(['stripe_price_id' => $stripePrice->id]);
|
||||||
|
|
||||||
|
Log::info('Price synced to Stripe', [
|
||||||
|
'price_id' => $price->id,
|
||||||
|
'stripe_price_id' => $stripePrice->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $stripePrice->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync product and its default price to Stripe
|
||||||
|
*
|
||||||
|
* @param Product $product
|
||||||
|
* @return array ['product_id' => string, 'price_id' => string]
|
||||||
|
*/
|
||||||
|
public function syncProductWithPrice(Product $product): array
|
||||||
|
{
|
||||||
|
$stripeProductId = $this->syncProduct($product);
|
||||||
|
|
||||||
|
$defaultPrice = $product->defaultPrice()->first();
|
||||||
|
|
||||||
|
if (!$defaultPrice) {
|
||||||
|
throw new \Exception("Product '{$product->name}' has no default price");
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripePriceId = $this->syncPrice($defaultPrice, $product);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'product_id' => $stripeProductId,
|
||||||
|
'price_id' => $stripePriceId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,11 @@ trait HasProductRelations
|
||||||
return $this->relationsByType(ProductRelationType::SINGLE);
|
return $this->relationsByType(ProductRelationType::SINGLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function poolProducts(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->relationsByType(ProductRelationType::POOL);
|
||||||
|
}
|
||||||
|
|
||||||
public function relationsByType(ProductRelationType|string $type): BelongsToMany
|
public function relationsByType(ProductRelationType|string $type): BelongsToMany
|
||||||
{
|
{
|
||||||
$typeValue = $type instanceof ProductRelationType ? $type->value : $type;
|
$typeValue = $type instanceof ProductRelationType ? $type->value : $type;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,655 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductRelationType;
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Enums\StockStatus;
|
||||||
|
use Blax\Shop\Enums\StockType;
|
||||||
|
use Blax\Shop\Exceptions\InvalidPoolConfigurationException;
|
||||||
|
|
||||||
|
trait MayBePoolProduct
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if this is a pool product
|
||||||
|
*/
|
||||||
|
public function isPool(): bool
|
||||||
|
{
|
||||||
|
return $this->type === ProductType::POOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the available quantity for this product
|
||||||
|
* For pool products, returns the count of available single items
|
||||||
|
* For regular products, returns available stock
|
||||||
|
*/
|
||||||
|
public function getAvailableQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int
|
||||||
|
{
|
||||||
|
if ($this->isPool()) {
|
||||||
|
return $this->getPoolMaxQuantity($from, $until);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getAvailableStock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the maximum available quantity for a pool product based on single items
|
||||||
|
*/
|
||||||
|
public function getPoolMaxQuantity(\DateTimeInterface $from = null, \DateTimeInterface $until = null): int
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
return $this->getAvailableStock();
|
||||||
|
}
|
||||||
|
|
||||||
|
$singleItems = $this->singleProducts;
|
||||||
|
|
||||||
|
if ($singleItems->isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no dates provided, sum up available stock from all single items
|
||||||
|
if (!$from || !$until) {
|
||||||
|
$hasUnlimitedItem = false;
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
foreach ($singleItems as $item) {
|
||||||
|
if (!$item->manage_stock) {
|
||||||
|
// Track if there's an unlimited item, but don't count it
|
||||||
|
$hasUnlimitedItem = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$total += $item->getAvailableStock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ALL items are unlimited, pool is unlimited
|
||||||
|
if ($hasUnlimitedItem && $total === 0) {
|
||||||
|
return PHP_INT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check availability for each single item during the timespan and sum their available quantities
|
||||||
|
$availableCount = 0;
|
||||||
|
$hasUnlimitedItem = false;
|
||||||
|
|
||||||
|
foreach ($singleItems as $item) {
|
||||||
|
// Track unlimited items but don't count them
|
||||||
|
if (!$item->manage_stock) {
|
||||||
|
$hasUnlimitedItem = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For booking items, check how many units are available for the period
|
||||||
|
if ($item->isBooking()) {
|
||||||
|
$availableStock = $item->getAvailableStock();
|
||||||
|
// Check if any quantity is available for booking
|
||||||
|
for ($qty = $availableStock; $qty > 0; $qty--) {
|
||||||
|
if ($item->isAvailableForBooking($from, $until, $qty)) {
|
||||||
|
$availableCount += $qty;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-booking items, just add their available stock
|
||||||
|
$availableCount += $item->getAvailableStock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ALL items are unlimited, pool is unlimited
|
||||||
|
if ($hasUnlimitedItem && $availableCount === 0) {
|
||||||
|
return PHP_INT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $availableCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim stock for a pool product
|
||||||
|
* This will claim stock from the available single items
|
||||||
|
*
|
||||||
|
* @param int $quantity Number of pool items to claim
|
||||||
|
* @param mixed $reference Reference model
|
||||||
|
* @param \DateTimeInterface|null $from Start date
|
||||||
|
* @param \DateTimeInterface|null $until End date
|
||||||
|
* @param string|null $note Optional note
|
||||||
|
* @return array Array of claimed single item products
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function claimPoolStock(
|
||||||
|
int $quantity,
|
||||||
|
$reference = null,
|
||||||
|
?\DateTimeInterface $from = null,
|
||||||
|
?\DateTimeInterface $until = null,
|
||||||
|
?string $note = null
|
||||||
|
): array {
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
throw new \Exception('This method is only for pool products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$singleItems = $this->singleProducts;
|
||||||
|
|
||||||
|
if ($singleItems->isEmpty()) {
|
||||||
|
throw new \Exception('Pool product has no single items to claim');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available single items for the period
|
||||||
|
$availableItems = [];
|
||||||
|
foreach ($singleItems as $item) {
|
||||||
|
if ($item->isAvailableForBooking($from, $until, 1)) {
|
||||||
|
$availableItems[] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($availableItems) >= $quantity) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($availableItems) < $quantity) {
|
||||||
|
throw new \Exception("Only " . count($availableItems) . " items available, but {$quantity} requested");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim stock from each selected single item
|
||||||
|
$claimedItems = [];
|
||||||
|
foreach (array_slice($availableItems, 0, $quantity) as $item) {
|
||||||
|
$item->claimStock(1, $reference, $from, $until, $note);
|
||||||
|
$claimedItems[] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $claimedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release pool stock claims
|
||||||
|
*
|
||||||
|
* @param mixed $reference Reference model used when claiming
|
||||||
|
* @return int Number of claims released
|
||||||
|
*/
|
||||||
|
public function releasePoolStock($reference): int
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
throw new \Exception('This method is only for pool products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$singleItems = $this->singleProducts;
|
||||||
|
$released = 0;
|
||||||
|
|
||||||
|
foreach ($singleItems as $item) {
|
||||||
|
$referenceType = is_object($reference) ? get_class($reference) : null;
|
||||||
|
$referenceId = is_object($reference) ? $reference->id : null;
|
||||||
|
|
||||||
|
// Find and delete claims for this reference
|
||||||
|
$claims = $item->stocks()
|
||||||
|
->where('type', StockType::CLAIMED->value)
|
||||||
|
->where('status', StockStatus::PENDING->value)
|
||||||
|
->where('reference_type', $referenceType)
|
||||||
|
->where('reference_id', $referenceId)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($claims as $claim) {
|
||||||
|
$claim->release();
|
||||||
|
$released++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $released;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any single item in pool is a booking product
|
||||||
|
*/
|
||||||
|
public function hasBookingSingleItems(): bool
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->singleProducts()->where('products.type', ProductType::BOOKING->value)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current price with pool product inheritance support
|
||||||
|
*/
|
||||||
|
public function getPoolCurrentPrice(bool|null $sales_price = null): ?float
|
||||||
|
{
|
||||||
|
// If this is a pool product and it has no direct price, inherit from single items
|
||||||
|
if ($this->isPool() && !$this->hasPrice()) {
|
||||||
|
return $this->getInheritedPoolPrice($sales_price);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pool has a direct price, use it
|
||||||
|
if ($this->isPool() && $this->hasPrice()) {
|
||||||
|
return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inherited price from single items based on pricing strategy
|
||||||
|
*/
|
||||||
|
protected function getInheritedPoolPrice(bool|null $sales_price = null): ?float
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$strategy = $this->getPoolPricingStrategy();
|
||||||
|
|
||||||
|
$singleItems = $this->singleProducts;
|
||||||
|
|
||||||
|
if ($singleItems->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prices = $singleItems->map(function ($item) use ($sales_price) {
|
||||||
|
return $item->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $item->isOnSale());
|
||||||
|
})->filter()->values();
|
||||||
|
|
||||||
|
if ($prices->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($strategy) {
|
||||||
|
'lowest' => $prices->min(),
|
||||||
|
'highest' => $prices->max(),
|
||||||
|
'average' => round($prices->avg()),
|
||||||
|
default => round($prices->avg()), // Default to average
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pool pricing strategy from metadata
|
||||||
|
*/
|
||||||
|
public function getPoolPricingStrategy(): string
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
return 'average';
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = $this->getMeta();
|
||||||
|
return $meta->pricing_strategy ?? 'average';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the pool pricing strategy
|
||||||
|
*/
|
||||||
|
public function setPoolPricingStrategy(string $strategy): void
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
throw new \Exception('This method is only for pool products');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($strategy, ['average', 'lowest', 'highest'])) {
|
||||||
|
throw new \InvalidArgumentException("Invalid pricing strategy: {$strategy}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateMetaKey('pricing_strategy', $strategy);
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach single items to this pool product
|
||||||
|
* Also creates reverse POOL relation from single items back to this pool
|
||||||
|
*
|
||||||
|
* @param array|int|string $singleItemIds Single product ID(s) to attach
|
||||||
|
* @param array $attributes Additional pivot attributes
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function attachSingleItems(array|int|string $singleItemIds, array $attributes = []): void
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
throw new \Exception('This method is only for pool products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = is_array($singleItemIds) ? $singleItemIds : [$singleItemIds];
|
||||||
|
|
||||||
|
// Attach single items to pool with SINGLE type
|
||||||
|
$this->productRelations()->attach(
|
||||||
|
array_fill_keys($ids, array_merge(['type' => ProductRelationType::SINGLE->value], $attributes))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attach reverse POOL relation from each single item back to this pool
|
||||||
|
foreach ($ids as $singleItemId) {
|
||||||
|
$singleItem = static::find($singleItemId);
|
||||||
|
if ($singleItem) {
|
||||||
|
$singleItem->productRelations()->attach(
|
||||||
|
$this->id,
|
||||||
|
array_merge(['type' => ProductRelationType::POOL->value], $attributes)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the lowest price from single items
|
||||||
|
*/
|
||||||
|
public function getLowestPoolPrice(): ?float
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$singleItems = $this->singleProducts;
|
||||||
|
|
||||||
|
if ($singleItems->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prices = $singleItems->map(function ($item) {
|
||||||
|
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
||||||
|
})->filter()->values();
|
||||||
|
|
||||||
|
return $prices->isEmpty() ? null : $prices->min();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the highest price from single items
|
||||||
|
*/
|
||||||
|
public function getHighestPoolPrice(): ?float
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$singleItems = $this->singleProducts;
|
||||||
|
|
||||||
|
if ($singleItems->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prices = $singleItems->map(function ($item) {
|
||||||
|
return $item->defaultPrice()->first()?->getCurrentPrice($item->isOnSale());
|
||||||
|
})->filter()->values();
|
||||||
|
|
||||||
|
return $prices->isEmpty() ? null : $prices->max();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the price range for pool products
|
||||||
|
*/
|
||||||
|
public function getPoolPriceRange(): ?array
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lowest = $this->getLowestPoolPrice();
|
||||||
|
$highest = $this->getHighestPoolPrice();
|
||||||
|
|
||||||
|
if ($lowest === null || $highest === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'min' => $lowest,
|
||||||
|
'max' => $highest,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate pool product configuration and provide helpful error messages
|
||||||
|
*
|
||||||
|
* @throws InvalidPoolConfigurationException
|
||||||
|
*/
|
||||||
|
public function validatePoolConfiguration(bool $throwOnWarnings = false): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
throw InvalidPoolConfigurationException::notAPoolProduct($this->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$singleItems = $this->singleProducts;
|
||||||
|
|
||||||
|
// Critical: No single items
|
||||||
|
if ($singleItems->isEmpty()) {
|
||||||
|
throw InvalidPoolConfigurationException::noSingleItems($this->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mixed product types
|
||||||
|
$types = $singleItems->pluck('type')->unique();
|
||||||
|
if ($types->count() > 1) {
|
||||||
|
$warning = "Mixed single item types detected. This may cause unexpected behavior.";
|
||||||
|
$warnings[] = $warning;
|
||||||
|
if ($throwOnWarnings) {
|
||||||
|
throw InvalidPoolConfigurationException::mixedSingleItemTypes($this->name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check stock management on single items
|
||||||
|
$itemsWithoutStock = $singleItems->filter(fn($item) => !$item->manage_stock);
|
||||||
|
if ($itemsWithoutStock->isNotEmpty()) {
|
||||||
|
$itemNames = $itemsWithoutStock->pluck('name')->toArray();
|
||||||
|
$errors[] = "Single items without stock management: " . implode(', ', $itemNames);
|
||||||
|
throw InvalidPoolConfigurationException::singleItemsWithoutStock($this->name, $itemNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for items with zero stock
|
||||||
|
$itemsWithZeroStock = $singleItems->filter(fn($item) => $item->getAvailableStock() <= 0);
|
||||||
|
if ($itemsWithZeroStock->isNotEmpty()) {
|
||||||
|
$itemNames = $itemsWithZeroStock->pluck('name')->toArray();
|
||||||
|
$warnings[] = "Single items with zero stock: " . implode(', ', $itemNames);
|
||||||
|
if ($throwOnWarnings) {
|
||||||
|
throw InvalidPoolConfigurationException::singleItemsWithZeroStock($this->name, $itemNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => empty($errors),
|
||||||
|
'errors' => $errors,
|
||||||
|
'warnings' => $warnings,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pool availability calendar showing how many items are available on each date.
|
||||||
|
* Returns an array with dates as keys and availability counts as values.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```php
|
||||||
|
* $pool = Product::find($id);
|
||||||
|
* $availability = $pool->getPoolAvailabilityCalendar('2025-01-01', '2025-01-07', 2);
|
||||||
|
*
|
||||||
|
* foreach ($availability as $date => $count) {
|
||||||
|
* echo "$date: $count items available\n";
|
||||||
|
* }
|
||||||
|
* // Output:
|
||||||
|
* // 2025-01-01: 3 items available
|
||||||
|
* // 2025-01-02: 2 items available
|
||||||
|
* // 2025-01-03: 1 items available
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface|string $startDate Start date for availability check
|
||||||
|
* @param \DateTimeInterface|string $endDate End date for availability check
|
||||||
|
* @param int $quantity How many items are needed (default 1)
|
||||||
|
* @return array Array with dates as keys and availability counts as values
|
||||||
|
*/
|
||||||
|
public function getPoolAvailabilityCalendar($startDate, $endDate, int $quantity = 1): array
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
throw new \Exception('This method is only for pool products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $startDate instanceof \DateTimeInterface ? $startDate : \Carbon\Carbon::parse($startDate);
|
||||||
|
$end = $endDate instanceof \DateTimeInterface ? $endDate : \Carbon\Carbon::parse($endDate);
|
||||||
|
|
||||||
|
$calendar = [];
|
||||||
|
$current = $start->copy();
|
||||||
|
|
||||||
|
while ($current <= $end) {
|
||||||
|
$dateStr = $current->format('Y-m-d');
|
||||||
|
$nextDay = $current->copy()->addDay();
|
||||||
|
|
||||||
|
// Check availability for this single day
|
||||||
|
$available = $this->getPoolMaxQuantity($current, $nextDay);
|
||||||
|
$calendar[$dateStr] = $available === PHP_INT_MAX ? 'unlimited' : $available;
|
||||||
|
|
||||||
|
$current->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed availability for each single item in the pool.
|
||||||
|
* Shows which specific items are available and their quantities.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```php
|
||||||
|
* $pool = Product::find($id);
|
||||||
|
* $items = $pool->getSingleItemsAvailability('2025-01-01', '2025-01-02');
|
||||||
|
*
|
||||||
|
* foreach ($items as $item) {
|
||||||
|
* echo "{$item['name']}: {$item['available']} available\n";
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface|string|null $from Start date (optional)
|
||||||
|
* @param \DateTimeInterface|string|null $until End date (optional)
|
||||||
|
* @return array Array of single items with their availability
|
||||||
|
*/
|
||||||
|
public function getSingleItemsAvailability($from = null, $until = null): array
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
throw new \Exception('This method is only for pool products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$singleItems = $this->singleProducts;
|
||||||
|
$availability = [];
|
||||||
|
|
||||||
|
if ($from && $until) {
|
||||||
|
$fromDate = $from instanceof \DateTimeInterface ? $from : \Carbon\Carbon::parse($from);
|
||||||
|
$untilDate = $until instanceof \DateTimeInterface ? $until : \Carbon\Carbon::parse($until);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($singleItems as $item) {
|
||||||
|
$available = 0;
|
||||||
|
|
||||||
|
if (!$item->manage_stock) {
|
||||||
|
$available = 'unlimited';
|
||||||
|
} elseif (isset($fromDate) && isset($untilDate)) {
|
||||||
|
// Check availability for the specific period
|
||||||
|
if ($item->isBooking()) {
|
||||||
|
$availableStock = $item->getAvailableStock();
|
||||||
|
// Check maximum available quantity for this period
|
||||||
|
for ($qty = $availableStock; $qty > 0; $qty--) {
|
||||||
|
if ($item->isAvailableForBooking($fromDate, $untilDate, $qty)) {
|
||||||
|
$available = $qty;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$available = $item->getAvailableStock();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No dates specified, get general stock
|
||||||
|
$available = $item->getAvailableStock();
|
||||||
|
}
|
||||||
|
|
||||||
|
$availability[] = [
|
||||||
|
'id' => $item->id,
|
||||||
|
'name' => $item->name,
|
||||||
|
'type' => $item->type->value,
|
||||||
|
'available' => $available,
|
||||||
|
'manage_stock' => $item->manage_stock,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the pool is available for a specific date range and quantity.
|
||||||
|
* A pool is NOT available if at least one single item is not available.
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface $from Start date
|
||||||
|
* @param \DateTimeInterface $until End date
|
||||||
|
* @param int $quantity Required quantity
|
||||||
|
* @return bool True if pool is available for the period
|
||||||
|
*/
|
||||||
|
public function isPoolAvailable(\DateTimeInterface $from, \DateTimeInterface $until, int $quantity = 1): bool
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
throw new \Exception('This method is only for pool products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxQuantity = $this->getPoolMaxQuantity($from, $until);
|
||||||
|
|
||||||
|
// Unlimited availability
|
||||||
|
if ($maxQuantity === PHP_INT_MAX) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $maxQuantity >= $quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available date ranges for the pool with a specific quantity.
|
||||||
|
* Returns periods where the pool has the required availability.
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface|string $startDate Start of search period
|
||||||
|
* @param \DateTimeInterface|string $endDate End of search period
|
||||||
|
* @param int $quantity Required quantity
|
||||||
|
* @param int $minConsecutiveDays Minimum consecutive days needed (default 1)
|
||||||
|
* @return array Array of available periods
|
||||||
|
*/
|
||||||
|
public function getPoolAvailablePeriods($startDate, $endDate, int $quantity = 1, int $minConsecutiveDays = 1): array
|
||||||
|
{
|
||||||
|
if (!$this->isPool()) {
|
||||||
|
throw new \Exception('This method is only for pool products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $startDate instanceof \DateTimeInterface ? $startDate : \Carbon\Carbon::parse($startDate);
|
||||||
|
$end = $endDate instanceof \DateTimeInterface ? $endDate : \Carbon\Carbon::parse($endDate);
|
||||||
|
|
||||||
|
$calendar = $this->getPoolAvailabilityCalendar($start, $end, $quantity);
|
||||||
|
$periods = [];
|
||||||
|
$currentPeriod = null;
|
||||||
|
|
||||||
|
foreach ($calendar as $date => $available) {
|
||||||
|
$isAvailable = ($available === 'unlimited' || $available >= $quantity);
|
||||||
|
|
||||||
|
if ($isAvailable) {
|
||||||
|
if ($currentPeriod === null) {
|
||||||
|
$currentPeriod = [
|
||||||
|
'from' => $date,
|
||||||
|
'until' => $date,
|
||||||
|
'min_available' => $available,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$currentPeriod['until'] = $date;
|
||||||
|
if ($available !== 'unlimited' && $currentPeriod['min_available'] !== 'unlimited') {
|
||||||
|
$currentPeriod['min_available'] = min($currentPeriod['min_available'], $available);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ($currentPeriod !== null) {
|
||||||
|
// Check if period meets minimum days requirement
|
||||||
|
$from = \Carbon\Carbon::parse($currentPeriod['from']);
|
||||||
|
$until = \Carbon\Carbon::parse($currentPeriod['until']);
|
||||||
|
$days = $from->diffInDays($until) + 1; // +1 to include both start and end dates
|
||||||
|
|
||||||
|
if ($days >= $minConsecutiveDays) {
|
||||||
|
$periods[] = $currentPeriod;
|
||||||
|
}
|
||||||
|
$currentPeriod = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add final period if exists
|
||||||
|
if ($currentPeriod !== null) {
|
||||||
|
$from = \Carbon\Carbon::parse($currentPeriod['from']);
|
||||||
|
$until = \Carbon\Carbon::parse($currentPeriod['until']);
|
||||||
|
$days = $from->diffInDays($until) + 1;
|
||||||
|
|
||||||
|
if ($days >= $minConsecutiveDays) {
|
||||||
|
$periods[] = $currentPeriod;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $periods;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -793,4 +793,203 @@ class CartAddToCartPoolPricingTest extends TestCase
|
||||||
$this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s'));
|
$this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s'));
|
||||||
$this->assertEquals(6000, $cartItem->price);
|
$this->assertEquals(6000, $cartItem->price);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_limits_pool_in_cart_quantity_by_single_products()
|
||||||
|
{
|
||||||
|
// Set price on pool
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $this->poolProduct->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 3000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert cart is empty
|
||||||
|
$this->assertEquals(0, $this->cart->items()->count());
|
||||||
|
|
||||||
|
// Assert poolProduct has quantity availability of 2 (based on 2 single items)
|
||||||
|
$availableQuantity = $this->poolProduct->getAvailableQuantity();
|
||||||
|
$this->assertEquals(2, $availableQuantity);
|
||||||
|
|
||||||
|
// Adding 2 pool items should succeed (without dates)
|
||||||
|
$cartItem = $this->cart->addToCart($this->poolProduct, 2);
|
||||||
|
$this->assertNotNull($cartItem);
|
||||||
|
$this->assertEquals(2, $cartItem->quantity);
|
||||||
|
|
||||||
|
// Try to add 1 more (total would be 3, but only 2 available)
|
||||||
|
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||||
|
$this->expectExceptionMessage('has only 2 items available');
|
||||||
|
$this->cart->addToCart($this->poolProduct, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_counts_single_item_stock_quantities_in_pool_availability()
|
||||||
|
{
|
||||||
|
// Create a pool with multiple single items having different stock quantities
|
||||||
|
$pool = Product::factory()->create([
|
||||||
|
'name' => 'Large Parking Pool',
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
'manage_stock' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create single items with different stock quantities
|
||||||
|
$spot1 = Product::factory()->create([
|
||||||
|
'name' => 'Standard Spot',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$spot1->increaseStock(5); // 5 units available
|
||||||
|
|
||||||
|
$spot2 = Product::factory()->create([
|
||||||
|
'name' => 'Premium Spot',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$spot2->increaseStock(3); // 3 units available
|
||||||
|
|
||||||
|
$spot3 = Product::factory()->create([
|
||||||
|
'name' => 'VIP Spot',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$spot3->increaseStock(2); // 2 units available
|
||||||
|
|
||||||
|
// Attach single items to pool
|
||||||
|
$pool->attachSingleItems([$spot1->id, $spot2->id, $spot3->id]);
|
||||||
|
|
||||||
|
// Pool should have availability = 5 + 3 + 2 = 10
|
||||||
|
$availableQuantity = $pool->getAvailableQuantity();
|
||||||
|
$this->assertEquals(10, $availableQuantity);
|
||||||
|
|
||||||
|
// Set price on pool
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $pool->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 3000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cart = Cart::factory()->create([
|
||||||
|
'customer_id' => $this->user->id,
|
||||||
|
'customer_type' => get_class($this->user),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should be able to add 10 pool items
|
||||||
|
$cartItem = $cart->addToCart($pool, 10);
|
||||||
|
$this->assertNotNull($cartItem);
|
||||||
|
$this->assertEquals(10, $cartItem->quantity);
|
||||||
|
|
||||||
|
// But not 11
|
||||||
|
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||||
|
$this->expectExceptionMessage('has only 10 items available');
|
||||||
|
$cart->addToCart($pool, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_counts_available_stock_with_booking_dates()
|
||||||
|
{
|
||||||
|
// Create pool with single items having stock
|
||||||
|
$pool = Product::factory()->create([
|
||||||
|
'name' => 'Conference Room Pool',
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
'manage_stock' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$room1 = Product::factory()->create([
|
||||||
|
'name' => 'Room A',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$room1->increaseStock(3);
|
||||||
|
|
||||||
|
$room2 = Product::factory()->create([
|
||||||
|
'name' => 'Room B',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$room2->increaseStock(2);
|
||||||
|
|
||||||
|
$pool->attachSingleItems([$room1->id, $room2->id]);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $pool->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 5000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(2)->startOfDay();
|
||||||
|
|
||||||
|
// Total availability should be 3 + 2 = 5
|
||||||
|
$this->assertEquals(5, $pool->getAvailableQuantity($from, $until));
|
||||||
|
|
||||||
|
$cart = Cart::factory()->create([
|
||||||
|
'customer_id' => $this->user->id,
|
||||||
|
'customer_type' => get_class($this->user),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should be able to book 5 pool items for the period
|
||||||
|
$cartItem = $cart->addToCart($pool, 5, [], $from, $until);
|
||||||
|
$this->assertNotNull($cartItem);
|
||||||
|
$this->assertEquals(5, $cartItem->quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_allows_unlimited_pool_when_single_items_dont_manage_stock()
|
||||||
|
{
|
||||||
|
// Create pool with single items that don't manage stock
|
||||||
|
$pool = Product::factory()->create([
|
||||||
|
'name' => 'Unlimited Parking Pool',
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
'manage_stock' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$spot1 = Product::factory()->create([
|
||||||
|
'name' => 'Unlimited Spot 1',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => false, // No stock management
|
||||||
|
]);
|
||||||
|
|
||||||
|
$spot2 = Product::factory()->create([
|
||||||
|
'name' => 'Unlimited Spot 2',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => false, // No stock management
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pool->attachSingleItems([$spot1->id, $spot2->id]);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $pool->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 3000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pool should have unlimited availability
|
||||||
|
$this->assertEquals(PHP_INT_MAX, $pool->getAvailableQuantity());
|
||||||
|
|
||||||
|
$cart = Cart::factory()->create([
|
||||||
|
'customer_id' => $this->user->id,
|
||||||
|
'customer_type' => get_class($this->user),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should be able to add any quantity without dates
|
||||||
|
$cartItem = $cart->addToCart($pool, 1000);
|
||||||
|
$this->assertNotNull($cartItem);
|
||||||
|
$this->assertEquals(1000, $cartItem->quantity);
|
||||||
|
|
||||||
|
// And with dates
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(2)->startOfDay();
|
||||||
|
|
||||||
|
$cartItem2 = $cart->addToCart($pool, 500, [], $from, $until);
|
||||||
|
$this->assertNotNull($cartItem2);
|
||||||
|
$this->assertEquals(500, $cartItem2->quantity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Models\Cart;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
class CartItemDateManagementTest extends TestCase
|
||||||
|
{
|
||||||
|
protected User $user;
|
||||||
|
protected Cart $cart;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->cart = Cart::factory()->create([
|
||||||
|
'customer_id' => $this->user->id,
|
||||||
|
'customer_type' => get_class($this->user),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_can_update_dates_on_cart_item()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 5000, // 50.00 per day
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add without dates
|
||||||
|
$cartItem = $this->cart->addToCart($product, 1);
|
||||||
|
$this->assertNull($cartItem->from);
|
||||||
|
$this->assertNull($cartItem->until);
|
||||||
|
|
||||||
|
// Update dates
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(4)->startOfDay(); // 3 days
|
||||||
|
|
||||||
|
$updated = $cartItem->updateDates($from, $until);
|
||||||
|
|
||||||
|
$this->assertEquals($from->format('Y-m-d H:i:s'), $updated->from->format('Y-m-d H:i:s'));
|
||||||
|
$this->assertEquals($until->format('Y-m-d H:i:s'), $updated->until->format('Y-m-d H:i:s'));
|
||||||
|
$this->assertEquals(15000, $updated->price); // 50.00 × 3 days
|
||||||
|
$this->assertEquals(15000, $updated->subtotal); // 150.00 × 1 quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_recalculates_price_when_updating_dates()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 10000, // 100.00 per day
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from1 = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until1 = Carbon::now()->addDays(3)->startOfDay(); // 2 days
|
||||||
|
|
||||||
|
$cartItem = $this->cart->addToCart($product, 2, [], $from1, $until1);
|
||||||
|
$this->assertEquals(20000, $cartItem->price); // 100 × 2 days
|
||||||
|
$this->assertEquals(40000, $cartItem->subtotal); // 200 × 2 quantity
|
||||||
|
|
||||||
|
// Update to longer period
|
||||||
|
$from2 = Carbon::now()->addDays(5)->startOfDay();
|
||||||
|
$until2 = Carbon::now()->addDays(10)->startOfDay(); // 5 days
|
||||||
|
|
||||||
|
$updated = $cartItem->updateDates($from2, $until2);
|
||||||
|
|
||||||
|
$this->assertEquals(50000, $updated->price); // 100 × 5 days
|
||||||
|
$this->assertEquals(100000, $updated->subtotal); // 500 × 2 quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_can_set_from_date_individually()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 5000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cartItem = $this->cart->addToCart($product, 1);
|
||||||
|
|
||||||
|
$from = Carbon::now()->addDays(1);
|
||||||
|
$updated = $cartItem->setFromDate($from);
|
||||||
|
|
||||||
|
$this->assertEquals($from->format('Y-m-d H:i:s'), $updated->from->format('Y-m-d H:i:s'));
|
||||||
|
$this->assertNull($updated->until);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_can_set_until_date_individually()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 5000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cartItem = $this->cart->addToCart($product, 1);
|
||||||
|
|
||||||
|
$until = Carbon::now()->addDays(3);
|
||||||
|
$updated = $cartItem->setUntilDate($until);
|
||||||
|
|
||||||
|
$this->assertNull($updated->from);
|
||||||
|
$this->assertEquals($until->format('Y-m-d H:i:s'), $updated->until->format('Y-m-d H:i:s'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_recalculates_when_both_dates_are_set()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 8000, // 80.00 per day
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cartItem = $this->cart->addToCart($product, 1);
|
||||||
|
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(5)->startOfDay(); // 4 days
|
||||||
|
|
||||||
|
// Set from first
|
||||||
|
$cartItem->setFromDate($from);
|
||||||
|
$this->assertNull($cartItem->fresh()->until);
|
||||||
|
$this->assertEquals(8000, $cartItem->fresh()->price); // Still default 1 day
|
||||||
|
|
||||||
|
// Set until - should trigger recalculation
|
||||||
|
$updated = $cartItem->setUntilDate($until);
|
||||||
|
|
||||||
|
$this->assertEquals(32000, $updated->price); // 80 × 4 days
|
||||||
|
$this->assertEquals(32000, $updated->subtotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_throws_exception_when_from_is_after_until()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 5000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cartItem = $this->cart->addToCart($product, 1);
|
||||||
|
|
||||||
|
$from = Carbon::now()->addDays(5);
|
||||||
|
$until = Carbon::now()->addDays(2);
|
||||||
|
|
||||||
|
$this->expectException(\Exception::class);
|
||||||
|
$this->expectExceptionMessage("'from' date must be before the 'until' date");
|
||||||
|
$cartItem->updateDates($from, $until);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_validates_dates_at_checkout_for_booking_products()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 5000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add booking product without dates
|
||||||
|
$this->cart->addToCart($product, 1);
|
||||||
|
|
||||||
|
// Should throw exception at checkout
|
||||||
|
$this->expectException(\Exception::class);
|
||||||
|
$this->expectExceptionMessage('missing required information: from, until');
|
||||||
|
$this->cart->checkout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_allows_checkout_when_dates_are_set()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $product->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 5000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = Carbon::now()->addDays(1);
|
||||||
|
$until = Carbon::now()->addDays(3);
|
||||||
|
|
||||||
|
$this->cart->addToCart($product, 1, [], $from, $until);
|
||||||
|
|
||||||
|
// Should not throw exception
|
||||||
|
$cart = $this->cart->checkout();
|
||||||
|
$this->assertNotNull($cart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,7 +56,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
|
||||||
{
|
{
|
||||||
$product = Product::factory()->create([
|
$product = Product::factory()->create([
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
ProductPrice::factory()->create([
|
ProductPrice::factory()->create([
|
||||||
'purchasable_id' => $product->id,
|
'purchasable_id' => $product->id,
|
||||||
|
|
@ -81,7 +83,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
|
||||||
{
|
{
|
||||||
$product = Product::factory()->create([
|
$product = Product::factory()->create([
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
ProductPrice::factory()->create([
|
ProductPrice::factory()->create([
|
||||||
'purchasable_id' => $product->id,
|
'purchasable_id' => $product->id,
|
||||||
|
|
@ -108,7 +112,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
|
||||||
{
|
{
|
||||||
$product = Product::factory()->create([
|
$product = Product::factory()->create([
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
|
$product->increaseStock(10);
|
||||||
|
|
||||||
ProductPrice::factory()->create([
|
ProductPrice::factory()->create([
|
||||||
'purchasable_id' => $product->id,
|
'purchasable_id' => $product->id,
|
||||||
|
|
@ -171,7 +177,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
|
||||||
$singleItem1 = Product::factory()->create([
|
$singleItem1 = Product::factory()->create([
|
||||||
'name' => 'Parking Spot 1',
|
'name' => 'Parking Spot 1',
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
|
$singleItem1->increaseStock(10);
|
||||||
|
|
||||||
ProductPrice::factory()->create([
|
ProductPrice::factory()->create([
|
||||||
'purchasable_id' => $singleItem1->id,
|
'purchasable_id' => $singleItem1->id,
|
||||||
|
|
@ -184,7 +192,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
|
||||||
$singleItem2 = Product::factory()->create([
|
$singleItem2 = Product::factory()->create([
|
||||||
'name' => 'Parking Spot 2',
|
'name' => 'Parking Spot 2',
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
|
$singleItem2->increaseStock(10);
|
||||||
|
|
||||||
ProductPrice::factory()->create([
|
ProductPrice::factory()->create([
|
||||||
'purchasable_id' => $singleItem2->id,
|
'purchasable_id' => $singleItem2->id,
|
||||||
|
|
@ -266,7 +276,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
|
||||||
$singleItem1 = Product::factory()->create([
|
$singleItem1 = Product::factory()->create([
|
||||||
'name' => 'Item 1',
|
'name' => 'Item 1',
|
||||||
'type' => ProductType::SIMPLE,
|
'type' => ProductType::SIMPLE,
|
||||||
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
|
$singleItem1->increaseStock(10);
|
||||||
|
|
||||||
ProductPrice::factory()->create([
|
ProductPrice::factory()->create([
|
||||||
'purchasable_id' => $singleItem1->id,
|
'purchasable_id' => $singleItem1->id,
|
||||||
|
|
@ -279,7 +291,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
|
||||||
$singleItem2 = Product::factory()->create([
|
$singleItem2 = Product::factory()->create([
|
||||||
'name' => 'Item 2',
|
'name' => 'Item 2',
|
||||||
'type' => ProductType::SIMPLE,
|
'type' => ProductType::SIMPLE,
|
||||||
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
|
$singleItem2->increaseStock(10);
|
||||||
|
|
||||||
ProductPrice::factory()->create([
|
ProductPrice::factory()->create([
|
||||||
'purchasable_id' => $singleItem2->id,
|
'purchasable_id' => $singleItem2->id,
|
||||||
|
|
@ -317,7 +331,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
|
||||||
$simpleItem = Product::factory()->create([
|
$simpleItem = Product::factory()->create([
|
||||||
'name' => 'Simple Item',
|
'name' => 'Simple Item',
|
||||||
'type' => ProductType::SIMPLE,
|
'type' => ProductType::SIMPLE,
|
||||||
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
|
$simpleItem->increaseStock(10);
|
||||||
|
|
||||||
ProductPrice::factory()->create([
|
ProductPrice::factory()->create([
|
||||||
'purchasable_id' => $simpleItem->id,
|
'purchasable_id' => $simpleItem->id,
|
||||||
|
|
@ -331,7 +347,9 @@ class CartItemRequiredAdjustmentsTest extends TestCase
|
||||||
$bookingItem = Product::factory()->create([
|
$bookingItem = Product::factory()->create([
|
||||||
'name' => 'Booking Item',
|
'name' => 'Booking Item',
|
||||||
'type' => ProductType::BOOKING,
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
]);
|
]);
|
||||||
|
$bookingItem->increaseStock(10);
|
||||||
|
|
||||||
ProductPrice::factory()->create([
|
ProductPrice::factory()->create([
|
||||||
'purchasable_id' => $bookingItem->id,
|
'purchasable_id' => $bookingItem->id,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class PoolAvailabilityMethodsTest extends TestCase
|
||||||
|
{
|
||||||
|
protected Product $pool;
|
||||||
|
protected Product $spot1;
|
||||||
|
protected Product $spot2;
|
||||||
|
protected Product $spot3;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->pool = Product::factory()->create([
|
||||||
|
'name' => 'Parking Pool',
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
'manage_stock' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->spot1 = Product::factory()->create([
|
||||||
|
'name' => 'Spot 1',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$this->spot1->increaseStock(2);
|
||||||
|
|
||||||
|
$this->spot2 = Product::factory()->create([
|
||||||
|
'name' => 'Spot 2',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$this->spot2->increaseStock(3);
|
||||||
|
|
||||||
|
$this->spot3 = Product::factory()->create([
|
||||||
|
'name' => 'Spot 3',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$this->spot3->increaseStock(1);
|
||||||
|
|
||||||
|
$this->pool->attachSingleItems([$this->spot1->id, $this->spot2->id, $this->spot3->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_gets_pool_availability_calendar()
|
||||||
|
{
|
||||||
|
$start = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$end = Carbon::now()->addDays(3)->startOfDay();
|
||||||
|
|
||||||
|
$calendar = $this->pool->getPoolAvailabilityCalendar($start, $end);
|
||||||
|
|
||||||
|
$this->assertIsArray($calendar);
|
||||||
|
$this->assertCount(3, $calendar); // 3 days
|
||||||
|
|
||||||
|
// Each day should have availability count
|
||||||
|
foreach ($calendar as $date => $available) {
|
||||||
|
$this->assertIsString($date);
|
||||||
|
$this->assertTrue($available === 'unlimited' || is_int($available));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_shows_correct_availability_per_day()
|
||||||
|
{
|
||||||
|
$start = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$end = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
|
||||||
|
$calendar = $this->pool->getPoolAvailabilityCalendar($start, $end);
|
||||||
|
|
||||||
|
// Total availability should be 2 + 3 + 1 = 6
|
||||||
|
$dateStr = $start->format('Y-m-d');
|
||||||
|
$this->assertEquals(6, $calendar[$dateStr]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_reduces_availability_when_items_are_claimed()
|
||||||
|
{
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(2)->startOfDay();
|
||||||
|
|
||||||
|
// Claim 2 items from spot1
|
||||||
|
$this->spot1->claimStock(2, null, $from, $until);
|
||||||
|
|
||||||
|
$calendar = $this->pool->getPoolAvailabilityCalendar($from, $until);
|
||||||
|
|
||||||
|
$dateStr = $from->format('Y-m-d');
|
||||||
|
// Should now be 0 (spot1) + 3 (spot2) + 1 (spot3) = 4
|
||||||
|
$this->assertEquals(4, $calendar[$dateStr]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_gets_single_items_availability_without_dates()
|
||||||
|
{
|
||||||
|
$availability = $this->pool->getSingleItemsAvailability();
|
||||||
|
|
||||||
|
$this->assertIsArray($availability);
|
||||||
|
$this->assertCount(3, $availability);
|
||||||
|
|
||||||
|
// Check structure
|
||||||
|
$this->assertEquals($this->spot1->id, $availability[0]['id']);
|
||||||
|
$this->assertEquals('Spot 1', $availability[0]['name']);
|
||||||
|
$this->assertEquals(2, $availability[0]['available']);
|
||||||
|
$this->assertTrue($availability[0]['manage_stock']);
|
||||||
|
|
||||||
|
$this->assertEquals(3, $availability[1]['available']);
|
||||||
|
$this->assertEquals(1, $availability[2]['available']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_gets_single_items_availability_with_dates()
|
||||||
|
{
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(2)->startOfDay();
|
||||||
|
|
||||||
|
// Claim some stock
|
||||||
|
$this->spot2->claimStock(2, null, $from, $until);
|
||||||
|
|
||||||
|
$availability = $this->pool->getSingleItemsAvailability($from, $until);
|
||||||
|
|
||||||
|
$this->assertEquals(2, $availability[0]['available']); // Spot 1: still 2
|
||||||
|
$this->assertEquals(1, $availability[1]['available']); // Spot 2: 3 - 2 claimed = 1
|
||||||
|
$this->assertEquals(1, $availability[2]['available']); // Spot 3: still 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_shows_unlimited_for_items_without_stock_management()
|
||||||
|
{
|
||||||
|
$unlimitedSpot = Product::factory()->create([
|
||||||
|
'name' => 'Unlimited Spot',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->pool->attachSingleItems($unlimitedSpot->id);
|
||||||
|
|
||||||
|
$availability = $this->pool->getSingleItemsAvailability();
|
||||||
|
|
||||||
|
$unlimited = collect($availability)->firstWhere('id', $unlimitedSpot->id);
|
||||||
|
$this->assertEquals('unlimited', $unlimited['available']);
|
||||||
|
$this->assertFalse($unlimited['manage_stock']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_checks_if_pool_is_available_for_period()
|
||||||
|
{
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(2)->startOfDay();
|
||||||
|
|
||||||
|
// Pool has 6 total items available
|
||||||
|
$this->assertTrue($this->pool->isPoolAvailable($from, $until, 6));
|
||||||
|
$this->assertTrue($this->pool->isPoolAvailable($from, $until, 3));
|
||||||
|
$this->assertFalse($this->pool->isPoolAvailable($from, $until, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_returns_false_when_pool_not_available()
|
||||||
|
{
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(2)->startOfDay();
|
||||||
|
|
||||||
|
// Claim all stock
|
||||||
|
$this->spot1->claimStock(2, null, $from, $until);
|
||||||
|
$this->spot2->claimStock(3, null, $from, $until);
|
||||||
|
$this->spot3->claimStock(1, null, $from, $until);
|
||||||
|
|
||||||
|
$this->assertFalse($this->pool->isPoolAvailable($from, $until, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_gets_available_periods_for_pool()
|
||||||
|
{
|
||||||
|
$start = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$end = Carbon::now()->addDays(10)->startOfDay();
|
||||||
|
|
||||||
|
// Claim stock for days 3-5
|
||||||
|
$claimFrom = Carbon::now()->addDays(3)->startOfDay();
|
||||||
|
$claimUntil = Carbon::now()->addDays(6)->startOfDay();
|
||||||
|
$this->spot1->claimStock(2, null, $claimFrom, $claimUntil);
|
||||||
|
$this->spot2->claimStock(3, null, $claimFrom, $claimUntil);
|
||||||
|
$this->spot3->claimStock(1, null, $claimFrom, $claimUntil);
|
||||||
|
|
||||||
|
$periods = $this->pool->getPoolAvailablePeriods($start, $end, 1);
|
||||||
|
|
||||||
|
$this->assertIsArray($periods);
|
||||||
|
// Should have availability before and after the claimed period
|
||||||
|
$this->assertGreaterThan(0, count($periods));
|
||||||
|
|
||||||
|
foreach ($periods as $period) {
|
||||||
|
$this->assertArrayHasKey('from', $period);
|
||||||
|
$this->assertArrayHasKey('until', $period);
|
||||||
|
$this->assertArrayHasKey('min_available', $period);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_filters_periods_by_minimum_consecutive_days()
|
||||||
|
{
|
||||||
|
$start = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$end = Carbon::now()->addDays(10)->startOfDay();
|
||||||
|
|
||||||
|
// Create a pattern with short availability gaps
|
||||||
|
$this->spot1->claimStock(
|
||||||
|
2,
|
||||||
|
null,
|
||||||
|
Carbon::now()->addDays(2)->startOfDay(),
|
||||||
|
Carbon::now()->addDays(3)->startOfDay()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->spot1->claimStock(
|
||||||
|
2,
|
||||||
|
null,
|
||||||
|
Carbon::now()->addDays(4)->startOfDay(),
|
||||||
|
Carbon::now()->addDays(5)->startOfDay()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get periods with minimum 3 consecutive days
|
||||||
|
$periods = $this->pool->getPoolAvailablePeriods($start, $end, 1, 3);
|
||||||
|
|
||||||
|
foreach ($periods as $period) {
|
||||||
|
$from = Carbon::parse($period['from']);
|
||||||
|
$until = Carbon::parse($period['until']);
|
||||||
|
$days = $from->diffInDays($until) + 1;
|
||||||
|
|
||||||
|
// All periods should have at least 3 days
|
||||||
|
$this->assertGreaterThanOrEqual(3, $days);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_handles_higher_quantity_requirements_in_available_periods()
|
||||||
|
{
|
||||||
|
$start = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$end = Carbon::now()->addDays(5)->startOfDay();
|
||||||
|
|
||||||
|
// Pool has 6 items total
|
||||||
|
// Get periods where at least 5 items are available
|
||||||
|
$periodsBefore = $this->pool->getPoolAvailablePeriods($start, $end, 5);
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, count($periodsBefore));
|
||||||
|
|
||||||
|
// Claim items to reduce availability below 5
|
||||||
|
$this->spot1->claimStock(
|
||||||
|
2,
|
||||||
|
null,
|
||||||
|
Carbon::now()->addDays(2)->startOfDay(),
|
||||||
|
Carbon::now()->addDays(4)->startOfDay()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now get periods where at least 5 items are available
|
||||||
|
$periodsAfter = $this->pool->getPoolAvailablePeriods($start, $end, 5);
|
||||||
|
|
||||||
|
// After claiming 2 items, only 4 items available during days 2-4
|
||||||
|
// So we should not be able to get periods with 5 items for the full range
|
||||||
|
// The periods should be different (either fewer or shorter)
|
||||||
|
$this->assertNotEquals($periodsBefore, $periodsAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_throws_exception_for_non_pool_products()
|
||||||
|
{
|
||||||
|
$regularProduct = Product::factory()->create([
|
||||||
|
'type' => ProductType::SIMPLE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(\Exception::class);
|
||||||
|
$this->expectExceptionMessage('This method is only for pool products');
|
||||||
|
$regularProduct->getPoolAvailabilityCalendar(Carbon::now(), Carbon::now()->addDays(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -131,7 +131,7 @@ class PoolProductCheckoutTest extends TestCase
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->expectException(\Exception::class);
|
$this->expectException(\Exception::class);
|
||||||
$this->expectExceptionMessage('requires a timespan');
|
$this->expectExceptionMessage('is missing required information: from, until');
|
||||||
|
|
||||||
$cart->checkout();
|
$cart->checkout();
|
||||||
}
|
}
|
||||||
|
|
@ -385,6 +385,8 @@ class PoolProductCheckoutTest extends TestCase
|
||||||
'purchasable_type' => Product::class,
|
'purchasable_type' => Product::class,
|
||||||
'quantity' => 1,
|
'quantity' => 1,
|
||||||
'price' => 20.00,
|
'price' => 20.00,
|
||||||
|
'from' => $from,
|
||||||
|
'until' => $until,
|
||||||
'parameters' => [
|
'parameters' => [
|
||||||
'from' => $from->toDateTimeString(),
|
'from' => $from->toDateTimeString(),
|
||||||
'until' => $until->toDateTimeString(),
|
'until' => $until->toDateTimeString(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductRelationType;
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
|
||||||
|
class PoolProductRelationsTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @test */
|
||||||
|
public function it_creates_reverse_pool_relation_when_attaching_single_items()
|
||||||
|
{
|
||||||
|
// Create pool product
|
||||||
|
$pool = Product::factory()->create([
|
||||||
|
'name' => 'Parking Pool',
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create single items
|
||||||
|
$spot1 = Product::factory()->create([
|
||||||
|
'name' => 'Parking Spot 1',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$spot2 = Product::factory()->create([
|
||||||
|
'name' => 'Parking Spot 2',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Use the helper method to attach single items
|
||||||
|
$pool->attachSingleItems([$spot1->id, $spot2->id]);
|
||||||
|
|
||||||
|
// Assert pool has single items
|
||||||
|
$this->assertEquals(2, $pool->singleProducts()->count());
|
||||||
|
$this->assertTrue($pool->singleProducts->contains($spot1));
|
||||||
|
$this->assertTrue($pool->singleProducts->contains($spot2));
|
||||||
|
|
||||||
|
// Assert single items have reverse pool relation
|
||||||
|
$this->assertEquals(1, $spot1->poolProducts()->count());
|
||||||
|
$this->assertEquals(1, $spot2->poolProducts()->count());
|
||||||
|
$this->assertTrue($spot1->poolProducts->contains($pool));
|
||||||
|
$this->assertTrue($spot2->poolProducts->contains($pool));
|
||||||
|
|
||||||
|
// Verify pivot type
|
||||||
|
$spot1Pivot = $spot1->productRelations()->where('related_product_id', $pool->id)->first();
|
||||||
|
$this->assertEquals(ProductRelationType::POOL->value, $spot1Pivot->pivot->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_can_attach_single_item_using_id()
|
||||||
|
{
|
||||||
|
$pool = Product::factory()->create([
|
||||||
|
'name' => 'Parking Pool',
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$spot = Product::factory()->create([
|
||||||
|
'name' => 'Parking Spot',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Attach single ID (not array)
|
||||||
|
$pool->attachSingleItems($spot->id);
|
||||||
|
|
||||||
|
$this->assertEquals(1, $pool->singleProducts()->count());
|
||||||
|
$this->assertTrue($pool->singleProducts->contains($spot));
|
||||||
|
$this->assertEquals(1, $spot->poolProducts()->count());
|
||||||
|
$this->assertTrue($spot->poolProducts->contains($pool));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_throws_exception_when_non_pool_tries_to_attach_single_items()
|
||||||
|
{
|
||||||
|
$regularProduct = Product::factory()->create([
|
||||||
|
'type' => ProductType::SIMPLE,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$spot = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->expectException(\Exception::class);
|
||||||
|
$this->expectExceptionMessage('This method is only for pool products');
|
||||||
|
$regularProduct->attachSingleItems($spot->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function single_item_can_belong_to_multiple_pools()
|
||||||
|
{
|
||||||
|
// Create two pools
|
||||||
|
$pool1 = Product::factory()->create([
|
||||||
|
'name' => 'Zone A Pool',
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pool2 = Product::factory()->create([
|
||||||
|
'name' => 'Zone B Pool',
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create single item
|
||||||
|
$spot = Product::factory()->create([
|
||||||
|
'name' => 'Flexible Spot',
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Attach to both pools
|
||||||
|
$pool1->attachSingleItems($spot->id);
|
||||||
|
$pool2->attachSingleItems($spot->id);
|
||||||
|
|
||||||
|
// Assert spot belongs to both pools
|
||||||
|
$this->assertEquals(2, $spot->poolProducts()->count());
|
||||||
|
$this->assertTrue($spot->poolProducts->contains($pool1));
|
||||||
|
$this->assertTrue($spot->poolProducts->contains($pool2));
|
||||||
|
|
||||||
|
// Assert each pool has the spot
|
||||||
|
$this->assertTrue($pool1->singleProducts->contains($spot));
|
||||||
|
$this->assertTrue($pool2->singleProducts->contains($spot));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_can_get_all_pools_for_a_single_item()
|
||||||
|
{
|
||||||
|
$pool1 = Product::factory()->create(['type' => ProductType::POOL, 'name' => 'Pool 1']);
|
||||||
|
$pool2 = Product::factory()->create(['type' => ProductType::POOL, 'name' => 'Pool 2']);
|
||||||
|
$pool3 = Product::factory()->create(['type' => ProductType::POOL, 'name' => 'Pool 3']);
|
||||||
|
|
||||||
|
$spot = Product::factory()->create(['type' => ProductType::BOOKING, 'name' => 'Spot']);
|
||||||
|
|
||||||
|
$pool1->attachSingleItems($spot->id);
|
||||||
|
$pool2->attachSingleItems($spot->id);
|
||||||
|
$pool3->attachSingleItems($spot->id);
|
||||||
|
|
||||||
|
$pools = $spot->poolProducts;
|
||||||
|
|
||||||
|
$this->assertCount(3, $pools);
|
||||||
|
$this->assertTrue($pools->contains('name', 'Pool 1'));
|
||||||
|
$this->assertTrue($pools->contains('name', 'Pool 2'));
|
||||||
|
$this->assertTrue($pools->contains('name', 'Pool 3'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function legacy_manual_attach_still_works()
|
||||||
|
{
|
||||||
|
// Test that old way of attaching still works (without reverse relation)
|
||||||
|
$pool = Product::factory()->create(['type' => ProductType::POOL]);
|
||||||
|
$spot = Product::factory()->create(['type' => ProductType::BOOKING]);
|
||||||
|
|
||||||
|
// Old way using productRelations()->attach() directly
|
||||||
|
$pool->productRelations()->attach($spot->id, ['type' => ProductRelationType::SINGLE->value]);
|
||||||
|
|
||||||
|
// Pool should have the single item
|
||||||
|
$this->assertEquals(1, $pool->singleProducts()->count());
|
||||||
|
$this->assertTrue($pool->singleProducts->contains($spot));
|
||||||
|
|
||||||
|
// But single item won't have reverse relation (unless manually added)
|
||||||
|
// This is expected behavior for legacy code
|
||||||
|
$this->assertEquals(0, $spot->poolProducts()->count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -420,7 +420,9 @@ class PoolProductTest extends TestCase
|
||||||
|
|
||||||
// Should detect booking items exist
|
// Should detect booking items exist
|
||||||
$this->assertTrue($mixedPool->hasBookingSingleItems());
|
$this->assertTrue($mixedPool->hasBookingSingleItems());
|
||||||
$this->assertEquals(2, $mixedPool->getPoolMaxQuantity());
|
// Pool max quantity should be the stock of managed items (1 from booking item)
|
||||||
|
// Unlimited items (simple item) don't contribute to the count
|
||||||
|
$this->assertEquals(1, $mixedPool->getPoolMaxQuantity());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
|
|
@ -505,8 +507,8 @@ class PoolProductTest extends TestCase
|
||||||
'type' => ProductRelationType::SINGLE->value,
|
'type' => ProductRelationType::SINGLE->value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Should still count as 1 item (not based on stock quantity)
|
// Should count the stock quantity (2), not just the number of items (1)
|
||||||
$this->assertEquals(1, $customPool->getPoolMaxQuantity($from, $until));
|
$this->assertEquals(2, $customPool->getPoolMaxQuantity($from, $until));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature;
|
||||||
|
|
||||||
|
use Blax\Shop\Enums\ProductRelationType;
|
||||||
|
use Blax\Shop\Enums\ProductType;
|
||||||
|
use Blax\Shop\Models\Cart;
|
||||||
|
use Blax\Shop\Models\Product;
|
||||||
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Workbench\App\Models\User;
|
||||||
|
|
||||||
|
class PoolSeparateCartItemsTest extends TestCase
|
||||||
|
{
|
||||||
|
protected User $user;
|
||||||
|
protected Cart $cart;
|
||||||
|
protected Product $pool;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->cart = Cart::factory()->create([
|
||||||
|
'customer_id' => $this->user->id,
|
||||||
|
'customer_type' => get_class($this->user),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create pool with single items
|
||||||
|
$this->pool = Product::factory()->create([
|
||||||
|
'type' => ProductType::POOL,
|
||||||
|
'manage_stock' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$spot1 = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$spot1->increaseStock(10);
|
||||||
|
|
||||||
|
$spot2 = Product::factory()->create([
|
||||||
|
'type' => ProductType::BOOKING,
|
||||||
|
'manage_stock' => true,
|
||||||
|
]);
|
||||||
|
$spot2->increaseStock(10);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $spot1->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 5000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProductPrice::factory()->create([
|
||||||
|
'purchasable_id' => $spot2->id,
|
||||||
|
'purchasable_type' => Product::class,
|
||||||
|
'unit_amount' => 5000,
|
||||||
|
'currency' => 'USD',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->pool->attachSingleItems([$spot1->id, $spot2->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_creates_separate_cart_items_for_different_dates()
|
||||||
|
{
|
||||||
|
$from1 = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until1 = Carbon::now()->addDays(3)->startOfDay();
|
||||||
|
|
||||||
|
$from2 = Carbon::now()->addDays(5)->startOfDay();
|
||||||
|
$until2 = Carbon::now()->addDays(7)->startOfDay();
|
||||||
|
|
||||||
|
$item1 = $this->cart->addToCart($this->pool, 1, [], $from1, $until1);
|
||||||
|
$item2 = $this->cart->addToCart($this->pool, 1, [], $from2, $until2);
|
||||||
|
|
||||||
|
// Should create two separate cart items
|
||||||
|
$this->assertNotEquals($item1->id, $item2->id);
|
||||||
|
$this->assertEquals(2, $this->cart->items()->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_merges_cart_items_with_same_dates_and_price()
|
||||||
|
{
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(3)->startOfDay();
|
||||||
|
|
||||||
|
$item1 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
|
||||||
|
$item2 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
|
||||||
|
|
||||||
|
// Should merge into one cart item
|
||||||
|
$this->assertEquals($item1->id, $item2->id);
|
||||||
|
$this->assertEquals(1, $this->cart->items()->count());
|
||||||
|
$this->assertEquals(2, $item2->quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_creates_separate_cart_items_when_price_changes()
|
||||||
|
{
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until = Carbon::now()->addDays(3)->startOfDay();
|
||||||
|
|
||||||
|
// Add first item
|
||||||
|
$item1 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
|
||||||
|
$price1 = $item1->price;
|
||||||
|
|
||||||
|
// Change the price on one of the single items
|
||||||
|
$singleItem = $this->pool->singleProducts->first();
|
||||||
|
$priceRecord = ProductPrice::where('purchasable_id', $singleItem->id)->first();
|
||||||
|
$priceRecord->update(['unit_amount' => 8000]); // Changed from 5000 to 8000
|
||||||
|
|
||||||
|
// Clear cache if any
|
||||||
|
$this->pool->refresh();
|
||||||
|
$singleItem->refresh();
|
||||||
|
|
||||||
|
// Add second item with same dates but different price
|
||||||
|
$item2 = $this->cart->addToCart($this->pool, 1, [], $from, $until);
|
||||||
|
|
||||||
|
// Should create separate cart items because price is different
|
||||||
|
$this->assertNotEquals($item1->id, $item2->id);
|
||||||
|
$this->assertEquals(2, $this->cart->items()->count());
|
||||||
|
$this->assertNotEquals($price1, $item2->price);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_creates_separate_cart_items_for_different_date_lengths()
|
||||||
|
{
|
||||||
|
$from = Carbon::now()->addDays(1)->startOfDay();
|
||||||
|
$until1 = Carbon::now()->addDays(3)->startOfDay(); // 2 days
|
||||||
|
$until2 = Carbon::now()->addDays(5)->startOfDay(); // 4 days
|
||||||
|
|
||||||
|
$item1 = $this->cart->addToCart($this->pool, 1, [], $from, $until1);
|
||||||
|
$item2 = $this->cart->addToCart($this->pool, 1, [], $from, $until2);
|
||||||
|
|
||||||
|
// Different date ranges mean different prices, so separate items
|
||||||
|
$this->assertNotEquals($item1->id, $item2->id);
|
||||||
|
$this->assertEquals(2, $this->cart->items()->count());
|
||||||
|
$this->assertNotEquals($item1->price, $item2->price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ abstract class TestCase extends Orchestra
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
|
ini_set('memory_limit', '256M');
|
||||||
|
|
||||||
Factory::guessFactoryNamesUsing(
|
Factory::guessFactoryNamesUsing(
|
||||||
fn(string $modelName) => match (true) {
|
fn(string $modelName) => match (true) {
|
||||||
str_starts_with($modelName, 'Workbench\\App\\') => 'Workbench\\Database\\Factories\\' . class_basename($modelName) . 'Factory',
|
str_starts_with($modelName, 'Workbench\\App\\') => 'Workbench\\Database\\Factories\\' . class_basename($modelName) . 'Factory',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue