PayPal checkouts that run through Stripe settle into our PayPal account. Stripe's ledger row books the gross and Stripe's fee, but PayPal takes its own fee inside the PayPal account, so net revenue was overstated. - Ledger rows carry `provider` (stripe, paypal) and `account`; existing rows default to stripe. - shop:import-paypal-ledger reads every account in shop.paypal.accounts through the Transaction Search API (read-only) and upserts one row per PayPal transaction. Stripe-originated payments and refunds add a `paypal_fee` row (amount 0, net -fee); payments PayPal swept on to Stripe are booked as `paypal_fee_passthrough`, because Stripe's fee already holds PayPal's. Direct PayPal sales count in full; holds, sweeps and withdrawals are non-revenue. - `paypal_fee` joins the default revenue types. - Stripe rows keep their payment intent in meta so PayPal rows find the buyer. - Shop::ledgerTotalsByAccount() splits the totals per account. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| Prices | ||
| ProductTypes | ||
| 01-products.md | ||
| 02-stripe.md | ||
| 03-purchasing.md | ||
| 04-stripe-checkout.md | ||
| 05-product-relations.md | ||
| 06-ledger.md | ||
| README.md | ||
README.md
Laravel Shop Documentation
Table of Contents
Product Types
- Overview & matrix — every product type at a glance plus which prices apply
- Booking Products — time-windowed reservations (
from/until) - Pool Products — interchangeable groups of booking items
- Simple Products — stand-alone single-SKU items (the default)
- Variable Products — parents of variants (T-shirt → S/M/L)
- Variation Products — the actual cartable variants
- Grouped Products — bundles / multi-packs
- External Products — affiliate / "view on partner site"
- Loanable Products — borrow → extend → return (library / rental)
Prices
- Price types & billing schemes —
ONE_TIMEvsRECURRING,PER_UNITvsTIERED, sale prices,ProductPriceTier
Core Features
- Products Overview - Basic product management
- Stripe Integration - Payment processing
- Purchasing - Order and purchase flow
- Stripe Checkout - Checkout integration
- Product Relations - How products relate to each other
Quick Start
Understanding Booking Products
Booking products are time-based items that can be reserved for specific date ranges. Perfect for:
- Hotel rooms
- Rental equipment
- Parking spaces
- Event tickets
- Service appointments
Understanding Pool Products
Pool products manage groups of individual items as a unified offering. Customers book from a "pool" and the system automatically assigns an available item. Ideal for:
- Hotel room categories ("Standard Rooms")
- Equipment fleets ("Rental Cars - Compact")
- Parking facilities ("Parking Spaces")
- General admission seating
Understanding Product Relations
The relation system enables complex product associations for marketing and structural purposes:
- Marketing: Upsells, cross-sells, related products
- Structural: Variations, bundles, pool/single items
Key Concepts
Stock Management
- Booking Products: Track time-based availability with claims
- Pool Products: Aggregate availability from single items
- Claims: Temporary stock reservations (cart, bookings)
Pricing Strategies
Pool products support flexible pricing:
- LOWEST (default): Cheapest available item
- HIGHEST: Most expensive available item
- AVERAGE: Average of available items
Prices are calculated from available items only, ensuring customers see accurate pricing.
Product Relations
Nine relation types for different purposes:
- RELATED, UPSELL, CROSS_SELL, DOWNSELL, ADD_ON (marketing)
- VARIATION, BUNDLE (structural)
- SINGLE, POOL (special bidirectional for pool management)
Common Workflows
Creating a Booking Product
$room = Product::create([
'name' => 'Deluxe Suite',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$room->increaseStock(5); // 5 rooms available
ProductPrice::create([
'purchasable_id' => $room->id,
'purchasable_type' => Product::class,
'unit_amount' => 20000, // $200/day
'is_default' => true,
]);
Creating a Pool Product
// 1. Create pool
$pool = Product::create([
'name' => 'Parking Spaces',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
// 2. Create single items
$spot1 = Product::create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$spot1->increaseStock(1);
// 3. Link them
$pool->attachSingleItems([$spot1->id, $spot2->id, $spot3->id]);
// 4. Set pricing strategy
$pool->setPricingStrategy(PricingStrategy::LOWEST);
Booking a Product
$from = Carbon::parse('2025-01-15');
$until = Carbon::parse('2025-01-20');
// Check availability
if ($product->isAvailableForBooking($from, $until, $quantity = 1)) {
// Add to cart (claims stock automatically)
$cart->addToCart($product, 1, [], $from, $until);
}
Setting Up Product Relations
// Cross-sells
$laptop->productRelations()->attach([
$mouse->id => ['type' => ProductRelationType::CROSS_SELL->value],
$bag->id => ['type' => ProductRelationType::CROSS_SELL->value],
]);
// Upsells
$basicPlan->productRelations()->attach($premiumPlan->id, [
'type' => ProductRelationType::UPSELL->value
]);
// Retrieve
$crossSells = $laptop->crossSellProducts;
$upsell = $basicPlan->upsellProducts->first();
Architecture Overview
Product Types
ProductType Enum:
├── SIMPLE → Stand-alone single-SKU products (default)
├── VARIABLE → Parents of variants
├── VARIATION → A specific variant of a Variable parent
├── GROUPED → Bundle / multi-pack of independent children
├── EXTERNAL → External / affiliate listing (no checkout)
├── BOOKING → Time-windowed reservations ⭐
├── POOL → Interchangeable group of booking items ⭐
└── LOANABLE → Borrow → extend → return (library / rental) ⭐
Relation Types
ProductRelationType Enum:
├── RELATED → Similar products
├── UPSELL → Premium alternatives
├── CROSS_SELL → Complementary products
├── DOWNSELL → Lower-priced alternatives
├── ADD_ON → Optional extras
├── VARIATION → Product variants
├── BUNDLE → Package components
├── SINGLE → Pool → Single items ⭐
└── POOL → Single item → Pool ⭐
Stock System
Stock Flow:
├── INCREASE → Add inventory (COMPLETED)
├── DECREASE → Remove inventory (COMPLETED)
├── CLAIMED → Reserve inventory (PENDING) ⭐
│ ├── Creates DECREASE entry (reduces available)
│ └── Creates CLAIMED entry (tracks reservation)
└── RETURN → Return to inventory (COMPLETED)
Available Stock = Sum(COMPLETED entries) - Sum(active CLAIMS)
Best Practices
Booking Products
✅ Always set manage_stock = true
✅ Check availability before booking
✅ Validate configuration with validateBookingConfiguration()
✅ Handle date ranges properly (from < until)
Pool Products
✅ Set pool manage_stock = false
✅ Set single items manage_stock = true
✅ Use attachSingleItems() for bidirectional relations
✅ Validate with validatePoolConfiguration()
✅ Set explicit pricing strategy
Relations
✅ Use appropriate relation types semantically
✅ Use helper methods (upsellProducts not manual queries)
✅ Eager load to avoid N+1 queries
✅ Add sort_order for display ordering
Troubleshooting
"Not enough stock available"
- Check if stock was added:
$product->increaseStock(5) - Check if stock is claimed by other bookings
- Verify
manage_stock = true
"Pool product has no single items"
- Use
attachSingleItems()to link items - Verify single items exist and have stock
Pool/Single relations not bidirectional
- Must use
attachSingleItems()not regularattach() - This creates both SINGLE and POOL relations automatically
Pricing shows wrong value
- Check pricing strategy:
$pool->getPricingStrategy() - Verify which items are available:
getSingleItemsAvailability() - Remember: only available items are priced
Development Tips
Testing Bookings
// Create test booking product
$product = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$product->increaseStock(10);
// Test availability
$this->assertTrue($product->isAvailableForBooking($from, $until, 5));
// Test booking
$cart->addToCart($product, 5, [], $from, $until);
$this->assertEquals(5, $product->getAvailableStock());
Testing Pools
// Create pool with items
$pool = Product::factory()->create(['type' => ProductType::POOL]);
$items = Product::factory()->count(3)->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$items->each->increaseStock(1);
$pool->attachSingleItems($items->pluck('id')->toArray());
// Test availability
$this->assertEquals(3, $pool->getPoolMaxQuantity($from, $until));
Debugging Relations
// Check relation exists
dd($product->productRelations()
->where('related_product_id', $otherId)
->where('type', ProductRelationType::RELATED->value)
->exists());
// Check bidirectional
dd($pool->singleProducts()->pluck('id'));
dd($singleItem->poolProducts()->pluck('id'));
Performance
Eager Loading
// Load all relations
Product::with([
'singleProducts',
'poolProducts',
'crossSellProducts',
'upsellProducts',
'prices'
])->get();
// Load nested
Product::with('singleProducts.prices')->get();
Caching
// Cache availability calendars
Cache::remember("pool:{$id}:availability", 3600, function() {
return $pool->getPoolAvailabilityCalendar($from, $until);
});
// Cache pricing
Cache::remember("pool:{$id}:price", 3600, function() {
return $pool->getCurrentPrice();
});
API Examples
Get Booking Availability
GET /api/products/{id}/availability?from=2025-01-15&until=2025-01-20
Response:
{
"available": true,
"max_quantity": 5,
"calendar": {
"2025-01-15": 5,
"2025-01-16": 3,
...
}
}
Get Pool Information
GET /api/products/{id}/pool-details
Response:
{
"pool": {
"id": 1,
"name": "Parking Spaces",
"pricing_strategy": "lowest"
},
"single_items": [
{"id": 10, "name": "Spot 1", "available": 1},
{"id": 11, "name": "Spot 2", "available": 0},
{"id": 12, "name": "Spot 3", "available": 1}
],
"price_range": {
"min": 2000,
"max": 3000
}
}
Contributing
When adding new features:
- Update relevant documentation
- Add test cases for booking/pool scenarios
- Consider stock management implications
- Validate relation logic
Support
For issues or questions:
- Check troubleshooting sections
- Review test cases for examples
- See related documentation for context