Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
466 changes: 466 additions & 0 deletions 2.x/addons/bundles.mdx

Large diffs are not rendered by default.

101 changes: 101 additions & 0 deletions 2.x/admin/bundles.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
title: "Bundles"
description: "The product-page bundle editor shipped by the Bundles add-on, the products table badge and filter, and how bundles appear in the order view."
---

The [Bundles](/2.x/addons/bundles) add-on (`lunarphp/bundles`) extends the admin panel's Catalog section so a merchant can turn any product variant into a bundle of other variants and see, on a component's own page, which bundles include it. It is an add-on section extension, built with the same extension API described under [Extending the panel](/2.x/admin/extending/overview), so it appears only when both `lunarphp/panel` and the add-on are installed.

Bundles are products, so the extension adds no navigation item, no permission, and no search source. A bundle is edited on its product's own pages, and product search already finds it.

## Setup

Install the add-on and run its migrations, then publish (or, in local development, link) its compiled assets so the cards load on the product pages:

```bash
php artisan vendor:publish --tag=bundles-panel-assets --force
# or every add-on at once
php artisan vendor:publish --tag=panel-all-assets --force
# or, locally
php artisan lunar:panel:link
```

See the [add-on installation](/2.x/addons/bundles#installation) for the full steps.

## Permission

Every route and card is gated by the Catalog section's existing products permission (`catalog:manage-products`), the same handle that grants the product pages themselves. Staff who can edit products can define bundles; there is nothing extra to assign. See [Access control](/2.x/admin/access-control).

## The Bundle card

The **Bundle** card is the editor. It appears in two places:

- On the **variant edit page**, below the main column (the `products.variants.edit:main:after` zone), as the full editor for that variant.
- On the **product edit page**, below the variants card (the `products.edit:variants:after` zone). For a single-variant product it is the full editor inline. For a multi-variant product it lists each variant with a "{count} components" badge or "Not a bundle", and an **Edit variant** link to the variant page where the bundle is edited, because a bundle is defined per variant.

The card loads its data on demand through the extension's own JSON routes, so products that are not bundles pay nothing beyond one request. Every change saves immediately through those routes, the way media and URLs save on the product page, rather than through the page's edit draft. The product's own drafted fields are untouched, and there is nothing to publish. A successful save shows a "Bundle saved." toast; a rejected change (a nesting or definition error) is listed at the top of the card.

### Sell as a bundle

The **Sell as a bundle** toggle creates the bundle definition on the variant (as a fixed-price bundle with no components), or removes it. Turning it off asks "Stop selling as a bundle?": the component list and option groups are removed, the variant keeps its prices, and it becomes an ordinary variant again.

A variant that is already a component of another bundle cannot be sold as a bundle, and the variant picker never offers a bundle variant as a component, so bundles cannot nest from the UI any more than from code.

### Pricing

Two modes, matching `Lunar\Bundles\Enums\BundlePricing`:

- **Fixed price**: the merchant sets the bundle variant's prices in the pricing editor as for any other variant.
- **Sum of components**: the price is derived from the components, less an optional **Discount** percentage taken off the component total. The card shows the resulting **Derived prices** per currency, with the undiscounted sum as the "Was" price when a discount applies, and names any component that has no price in a currency, since the bundle cannot be priced in that currency until it does.

In this mode the derived rows appear in the variant's pricing editor like any other price, but a note on the card warns that they are overwritten whenever the bundle is repriced. To hand-tune the price, switch to a fixed price. See [Pricing](/2.x/addons/bundles#pricing) for what triggers a reprice.

### Components

The **Components** list holds the fixed components: variants that are always included, in the order shown. **Add component** opens the variant picker (search by name or SKU; bundle variants and the variant being edited are excluded). Each row shows the component's thumbnail, product name, option, and SKU with a **Qty** field for units per bundle, and can be moved up or down or removed. The list must hold at least one component before the bundle can be sold, and no more than `lunar.bundles.max_components` in total.

### Option groups

**Option groups** turn a fixed bundle into a configurable one. **Add group** creates one named "New group" with **Min** and **Max** of `1`. Each group has a **Group name** (edited in the store's default language; the field is marked with the language code), **Min** and **Max** selections (a minimum of `0` makes the group optional; a maximum of `1` is "pick one"), and its own list of options added with **Add option**, which opens the same picker. Marking an option **Default** pre-selects it: the default selection is what the derived price is computed from, and what the storefront sells when the customer does not choose. A group's maximum cannot exceed the number of options it holds. **Remove group** asks for confirmation and removes the group's options with it.

### Availability

The line beside the **Sell as a bundle** toggle is the bundle's derived stock: "{count} can be sold from component stock.", or "Not limited by component stock." when every part sells without a stock limit, or "Out of stock: a component is unavailable." This is the figure the storefront and the cart see for the bundle variant.

The first-party **Inventory** card on the same variant page still shows the bundle variant's own stock levels. Those figures are ignored for a bundle: the variant never holds stock, nothing is committed against it, and they stay at zero. The Bundle card's availability line is the one that matters.

## The Included in bundles card

On the product edit page sidebar (the `products.edit:sidebar:after` zone), the **Included in bundles** card lists every bundle that includes any of this product's variants, each linking to the bundle's product, with the quantity of this product's variant in it. It is hidden when the product is in no bundle.

Deleting a variant removes it from these bundles, so this card is the merchant's warning before doing that.

## Products table

The products table gains a **Bundle** column, showing a **Bundle** badge on products with at least one bundle variant and an empty cell otherwise, and a **Bundles** filter with a single **Bundles only** option that limits the table to those products. The column is computed with a single exists-subquery, so the table stays one query.

## Orders

The order view needs nothing from the extension: nesting of component lines is first-party panel behavior, driven by the `parent_line_id` the add-on sets when the order is created. See [Component lines](/2.x/reference/orders#component-lines).

- **Fulfilment cards** list the component lines, since those are what is picked and shipped. Each shows a "Part of {parent}" hint under its description and its quantity in parts, with no unit price, so the pick list reads "Camera body, part of Camera starter kit, 1".
- **Other items**, the card for lines that are not in any fulfilment, shows the bundle's parent line with its price and totals, and its components nested beneath it as description, option, SKU, and quantity only.
- **The refund composer** lists only lines with a refundable quantity. A component line's refundable quantity is always zero, so a refund of one damaged part is recorded as an amount against the bundle line, which holds the money.

## Routes

All routes are named `panel.bundles.*`, live under `{panel path}/bundles` (`/panel/bundles` by default), return JSON, and sit behind the products permission:

| Route | Method and path | Purpose |
| --- | --- | --- |
| `panel.bundles.search-variants` | `GET /bundles/search-variants?q=&exclude=` | Variant picker search; excludes bundle variants and the variant being edited |
| `panel.bundles.variants.show` | `GET /bundles/variants/{variant}` | Summary JSON for the Bundle card on a variant |
| `panel.bundles.variants.update` | `PUT /bundles/variants/{variant}` | Define the bundle, or change its pricing mode and discount |
| `panel.bundles.variants.destroy` | `DELETE /bundles/variants/{variant}` | Remove the definition |
| `panel.bundles.variants.components` | `PUT /bundles/variants/{variant}/components` | Replace the component list |
| `panel.bundles.variants.groups` | `PUT /bundles/variants/{variant}/groups` | Replace the option groups |
| `panel.bundles.products.show` | `GET /bundles/products/{product}` | One summary per variant, for the card on a product page |
| `panel.bundles.products.included-in` | `GET /bundles/products/{product}/included-in` | JSON for the Included in bundles card |

Every mutating route answers with the same summary the `show` route returns, so the card replaces its state wholesale after each save.

The Filament admin (`lunarphp/admin`) has no bundle editor; bundles are defined in code or through the panel.
2 changes: 1 addition & 1 deletion 2.x/admin/extending/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ An add-on page renders inside the real panel chrome — nav sidebar, mobile draw

### Using the standard page scaffolding

The panel exposes a page-building set at runtime through `@lunarphp/panel` — layout and chrome (`PageHeader`, `PageZone`, `Breadcrumbs`, `SettingsShell`), data (`DataTable`, `Pagination`, `PageEmpty`, `StatusBadge`), filters and stats (`FilterDropdown`, `KpiCard`), form inputs (`TextInput`, `Select`, `Checkbox`, and so on), overlays (`Dialog`, `Slideout`, `ConfirmDialog`, `Tooltip`, `SideCard`, `Tabs`), and `Button`/`Icon`. The add-on's Vite plugin externalizes the `@lunarphp/panel` import to the panel's own components (`window.LunarPanelUI`) exactly the way it externalizes `vue`, so nothing is duplicated. From `resources/js/pages/Widgets/Index.vue`:
The panel exposes a page-building set at runtime through `@lunarphp/panel` — layout and chrome (`PageHeader`, `PageZone`, `Breadcrumbs`, `SettingsShell`), data (`DataTable`, `Pagination`, `PageEmpty`, `StatusBadge`), filters and stats (`FilterDropdown`, `KpiCard`), form inputs (`TextInput`, `Select`, `Checkbox`, and so on), overlays (`Dialog`, `Slideout`, `ConfirmDialog`, `Tooltip`, `Tabs`), cards (`Section` for a main-column card, `SideCard` for the sidebar), and `Button`/`Icon`. The add-on's Vite plugin externalizes the `@lunarphp/panel` import to the panel's own components (`window.LunarPanelUI`) exactly the way it externalizes `vue`, so nothing is duplicated. From `resources/js/pages/Widgets/Index.vue`:

```vue
<script setup lang="ts">
Expand Down
16 changes: 16 additions & 0 deletions 2.x/guides/cart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,22 @@ class CartController extends Controller
}
```

## Selling Bundles

With the [Bundles add-on](/2.x/addons/bundles) installed, a bundle is added to the cart as its product variant with the same `add()` call as any other product. A fixed bundle needs nothing extra; a configurable bundle passes the customer's choices in the line's `meta`, keyed by the group's `public_id` and listing the chosen components' `public_id`s:

```php
$cart->add($variant, 1, [
'bundle' => [
'selections' => [
$group->public_id => [$component->public_id],
],
],
]);
```

The add-on's validator rejects a selection the bundle cannot satisfy, or one whose components are out of stock, with a `CartException` like any other validator. The cart line's `purchasable` is the bundle variant, so the display code above works unchanged; the parts appear only on the order, as [component lines](/2.x/reference/orders#component-lines). See [Cart and checkout](/2.x/addons/bundles#cart-and-checkout) in the add-on documentation for the full flow.

## Next Steps

- Review the [Carts reference](/2.x/reference/carts) for the full list of cart and cart line fields, configuration options, and session management.
Expand Down
67 changes: 65 additions & 2 deletions 2.x/reference/inventory.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ $variant->getTotalInventory(); // sellable quantity, accounting for the
$variant->canBeFulfilledAtQuantity(3); // bool
```

`getTotalInventory()` reads `stock_available`, adding `backorder` when the variant's `Lunar\Core\Enums\SellingPolicy` is `InStockOrOnBackorder`:
Both methods delegate to the [`ResolvesInventory` seam](#the-resolvesinventory-seam) below. The default implementation reads `stock_available`, adding `backorder` when the variant's `Lunar\Core\Enums\SellingPolicy` is `InStockOrOnBackorder`:

| Case | Value | `getTotalInventory()` |
|:-|:-|:-|
Expand All @@ -283,6 +283,69 @@ $variant->canBeFulfilledAtQuantity(3); // bool
The add-to-cart stock check is advisory: with no reservation taken at add-to-cart, stock can still sell out before an order is placed. `reserveStock()` is the supported way for a checkout to hold stock, optionally time-boxed, and close that window for flows that opt in.
</Info>

## The `ResolvesInventory` seam

`ProductVariant::getTotalInventory()` and `canBeFulfilledAtQuantity()` do not read the rollup columns themselves. They resolve `Lunar\Core\Contracts\Actions\Products\ResolvesInventory` from the container and read its answer, so a package that derives a variant's availability from somewhere other than the variant's own stock (a bundle from its components, an external warehouse feed) can change the answer for every caller at once: the cart's `CartLineStock` validator, a storefront stock badge, and the admin panel all go through the same two methods.

```php
namespace Lunar\Core\Contracts\Actions\Products;

use Lunar\Core\DataObjects\VariantInventory;
use Lunar\Core\Models\ProductVariant;

interface ResolvesInventory
{
public function execute(ProductVariant $variant): VariantInventory;
}
```

The answer is a `Lunar\Core\DataObjects\VariantInventory`, a small read-only value:

| Property / method | Type | Description |
|:-|:-|:-|
| `available` | `int` | Units that can be sold now |
| `unlimited` | `bool` | Selling continues regardless of `available` (the `Always` selling policy); defaults to `false` |
| `allows(int $quantity)` | `bool` | `true` when `unlimited`, or when `$quantity` does not exceed `available` |

`getTotalInventory()` returns `available`; `canBeFulfilledAtQuantity($quantity)` returns `allows($quantity)`.

The default binding, `Lunar\Core\Actions\Products\ResolveInventory`, registered in the core `ActionServiceProvider`, applies the selling-policy table above to the variant's rollup columns. To answer differently for some variants and fall through for the rest, decorate the binding rather than replacing it:

```php
use Lunar\Core\Contracts\Actions\Products\ResolvesInventory;
use Lunar\Core\DataObjects\VariantInventory;
use Lunar\Core\Models\ProductVariant;

class ResolveWarehouseInventory implements ResolvesInventory
{
public function __construct(
protected ResolvesInventory $inner,
protected WarehouseFeed $feed,
) {}

public function execute(ProductVariant $variant): VariantInventory
{
if (! $variant->warehouse_sku) {
return $this->inner->execute($variant);
}

return new VariantInventory(available: $this->feed->availableFor($variant->warehouse_sku));
}
}
```

```php
// In a service provider's register():
$this->app->extend(
ResolvesInventory::class,
fn (ResolvesInventory $inner, $app) => new ResolveWarehouseInventory($inner, $app->make(WarehouseFeed::class)),
);
```

Decorators answer other variants through `$this->inner`, never through `$variant->getTotalInventory()`, which would re-enter the chain. The [Bundles add-on](/2.x/addons/bundles#stock) is the reference example: its decorator answers a bundle variant from its components' inventories and falls through for everything else.

The seam covers sellability only. Recording movements, reservations, and commitment stay on the ledger and the order book as described above.

## Custom purchasables: the `TracksStock` capability

Anything sold implements `Lunar\Core\Contracts\Purchasable`, but not everything sold tracks stock — a gift card or a service can answer `canBeFulfilledAtQuantity()` without a `StockLevel` behind it. Stock participation is the separate, opt-in `Lunar\Core\Contracts\TracksStock` capability:
Expand All @@ -301,7 +364,7 @@ interface TracksStock
}
```

The cart, checkout, and order lifecycle call these two methods polymorphically through `instanceof TracksStock`, skipping any purchasable that does not track stock. `ProductVariant` is the only built-in implementation (via `Lunar\Core\Models\Concerns\HasStock`); a custom stock-tracked purchasable (event seats, a bundle, an external warehouse system) implements `TracksStock` with its own storage, since its stock rarely looks like an integer `on_hand` at a location.
The cart, checkout, and order lifecycle call these two methods polymorphically through `instanceof TracksStock`, skipping any purchasable that does not track stock. `ProductVariant` is the only built-in implementation (via `Lunar\Core\Models\Concerns\HasStock`); a custom stock-tracked purchasable (event seats, a made-to-order assembly, an external warehouse system) implements `TracksStock` with its own storage, since its stock rarely looks like an integer `on_hand` at a location.

## Reconciling and expiring reservations

Expand Down
Loading