> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lunarphp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Discounts

> Percentage, fixed-amount, and buy-x-get-y discounts, scoped by channel, customer group, and targeted products, applied during cart calculation.

Lunar applies discounts to a cart through a registry of discount type classes, each reading its own configuration from the discount's `data` column.

## Overview

A `Lunar\Core\Models\Discount` record describes *when* a discount is active, *who* it is available to, and *what* it targets. The `type` column names a class implementing `Lunar\Core\Contracts\DiscountType` that decides *how* the discount is applied to a cart. Lunar ships three types: percentage off, fixed amount off, and buy-x-get-y.

```php theme={null}
Lunar\Core\Models\Discount
```

| Field               | Type                               | Description                                                        |
| :------------------ | :--------------------------------- | :----------------------------------------------------------------- |
| `id`                | `bigIncrements`                    | Primary key                                                        |
| `public_id`         | `ulid`                             | Public-facing identifier                                           |
| `name`              | `string`                           |                                                                    |
| `handle`            | `string`                           | Unique identifier                                                  |
| `coupon`            | `string` `nullable`                | Coupon code a customer enters to apply the discount                |
| `type`              | `string`                           | Fully qualified class name of the discount type                    |
| `starts_at`         | `dateTime`                         | When the discount becomes active                                   |
| `ends_at`           | `dateTime` `nullable`              | When the discount expires. No expiry if `null`                     |
| `uses`              | `unsignedInteger`                  | How many times the discount has been used                          |
| `max_uses`          | `unsignedMediumInteger` `nullable` | Maximum times this discount can be used storewide                  |
| `priority`          | `unsignedMediumInteger`            | Order discounts are evaluated in, highest first (default: `1`)     |
| `stop`              | `boolean`                          | Whether to stop evaluating further discounts once this one applies |
| `restriction`       | `string` `nullable`                | Reserved; not currently read by any shipped discount type          |
| `data`              | `jsonb` `nullable`                 | Discount-type-specific configuration                               |
| `created_at`        | `timestamp`                        |                                                                    |
| `updated_at`        | `timestamp`                        |                                                                    |
| `max_uses_per_user` | `unsignedMediumInteger` `nullable` | Maximum times a single user can use this discount                  |

`coupon` is cast through `Lunar\Core\Casts\CouponString`, which normalizes the stored value to uppercase.

### Relationships

| Relationship              | Type            | Related Model                     | Description                                                                                                                        |
| :------------------------ | :-------------- | :-------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------- |
| `users`                   | Belongs to many | the app's user model              | Users who have used this discount                                                                                                  |
| `customers`               | Belongs to many | `Lunar\Core\Models\Customer`      | Customers the discount is restricted to. Empty means all customers                                                                 |
| `customerGroups`          | Belongs to many | `Lunar\Core\Models\CustomerGroup` | Customer groups the discount is available to                                                                                       |
| `channels`                | Morph to many   | `Lunar\Core\Models\Channel`       | Channels the discount is available on                                                                                              |
| `collections`             | Belongs to many | `Lunar\Core\Models\Collection`    | Collections associated with the discount, scoped by pivot `type` (`limitation`, `exclusion`, or `condition`; default `limitation`) |
| `brands`                  | Belongs to many | `Lunar\Core\Models\Brand`         | Brands associated with the discount, scoped by pivot `type` (`limitation`, `exclusion`, or `condition`; default `limitation`)      |
| `discountables`           | Has many        | `Lunar\Core\Models\Discountable`  | All discountable entries for this discount                                                                                         |
| `discountableConditions`  | Has many        | `Lunar\Core\Models\Discountable`  | Entries with `type = condition`                                                                                                    |
| `discountableExclusions`  | Has many        | `Lunar\Core\Models\Discountable`  | Entries with `type = exclusion`                                                                                                    |
| `discountableLimitations` | Has many        | `Lunar\Core\Models\Discountable`  | Entries with `type = limitation`                                                                                                   |
| `discountableRewards`     | Has many        | `Lunar\Core\Models\Discountable`  | Entries with `type = reward`, used by buy-x-get-y                                                                                  |

### Scopes

| Scope                                                              | Description                                                                                                                |
| :----------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| `active()`                                                         | Discounts that have started and have not ended                                                                             |
| `expired()`                                                        | Discounts whose `ends_at` has passed                                                                                       |
| `scheduled()`                                                      | Discounts whose `starts_at` is in the future                                                                               |
| `pending()`                                                        | Discounts with no `starts_at` set that have not ended                                                                      |
| `usable(iterable $exempt = [])`                                    | Discounts under `max_uses`, or with no `max_uses`. `$exempt` lets already-consumed discount IDs stay usable for re-pricing |
| `products(iterable $productIds, array\|string $types = [])`        | Filters by associated product IDs and discountable `type`                                                                  |
| `productVariants(iterable $variantIds, array\|string $types = [])` | Filters by associated variant IDs and discountable `type`                                                                  |
| `collections(iterable $collectionIds, array\|string $types = [])`  | Filters by associated collection IDs and pivot `type`                                                                      |
| `brands(iterable $brandIds, array\|string $types = [])`            | Filters by associated brand IDs and pivot `type`                                                                           |
| `channel($channel)`                                                | Filters to discounts available on the given channel(s)                                                                     |
| `customerGroup($customerGroup)`                                    | Filters to discounts available to the given customer group(s)                                                              |

### The status attribute

```php theme={null}
use Lunar\Core\Models\Discount;

$discount = Discount::find(1);

$discount->status; // 'active', 'pending', 'expired', or 'scheduled'
```

| Status      | Constant              | Description                                         |
| :---------- | :-------------------- | :-------------------------------------------------- |
| `active`    | `Discount::ACTIVE`    | The discount has started and has not ended          |
| `pending`   | `Discount::PENDING`   | No `starts_at` is in the past, and it has not ended |
| `expired`   | `Discount::EXPIRED`   | The discount's `ends_at` has passed                 |
| `scheduled` | `Discount::SCHEDULED` | The discount's `starts_at` is in the future         |

## Targeting: Discountable

`Lunar\Core\Models\Discountable` links a product, product variant, or collection to a discount, tagged with a role via its `type` column.

```php theme={null}
Lunar\Core\Models\Discountable
```

| Field               | Type                 | Description                                                                                           |
| :------------------ | :------------------- | :---------------------------------------------------------------------------------------------------- |
| `id`                | `bigIncrements`      | Primary key                                                                                           |
| `discount_id`       | `foreignId`          |                                                                                                       |
| `discountable_type` | `string`             | Morph type (e.g. product, product variant)                                                            |
| `discountable_id`   | `unsignedBigInteger` | Morph ID                                                                                              |
| `type`              | `string`             | The role this entry plays: `condition`, `exclusion`, `limitation`, or `reward` (default: `condition`) |
| `created_at`        | `timestamp`          |                                                                                                       |
| `updated_at`        | `timestamp`          |                                                                                                       |

* **`condition`**: must be in the cart for the discount to activate (used by buy-x-get-y for the "buy" side).
* **`exclusion`**: excluded from a percentage-off or fixed-amount-off discount's eligible lines.
* **`limitation`**: a percentage-off or fixed-amount-off discount only applies to these products or variants.
* **`reward`**: given as the reward by a buy-x-get-y discount.

### Relationships

| Relationship   | Type       | Related Model                | Description              |
| :------------- | :--------- | :--------------------------- | :----------------------- |
| `discount`     | Belongs to | `Lunar\Core\Models\Discount` | The parent discount      |
| `discountable` | Morph to   | `Product`, `ProductVariant`  | The targeted purchasable |

### Scopes

| Scope         | Description                                |
| :------------ | :----------------------------------------- |
| `condition()` | Filters to entries with `type = condition` |

Percentage-off and fixed-amount-off discounts also target lines by collection or brand directly through the `Discount::collections()` and `Discount::brands()` relationships (pivot `type` of `limitation` or `exclusion`), rather than through `Discountable`.

## Built-in discount types

Every discount type extends `Lunar\Core\DiscountTypes\AbstractDiscountType` and implements `Lunar\Core\Contracts\DiscountType`:

```php theme={null}
interface DiscountType
{
    public function getName(): string;
    public function apply(Cart $cart): Cart;
}
```

`AbstractDiscountType` provides shared behavior every type relies on:

* `checkDiscountConditions(Cart $cart): bool`: validates the discount against the cart before it applies anything, including a matching coupon code (if the discount requires one), the cart customer against `customers()` (if restricted), a `data.min_prices` minimum spend for the cart's currency, `max_uses`, and `max_uses_per_user`. Every shipped type calls this at the top of `apply()`.
* `markAsUsed(Cart $cart)`: increments `uses` and attaches the cart's user.
* `addDiscountBreakdown(Cart $cart, DiscountBreakdown $breakdown)`: records how much this discount took off, and which lines it affected, on `$cart->discountBreakdown`.

`data.min_prices` is keyed by currency code and stored in minor units, and applies regardless of which type is used:

```php theme={null}
'data' => [
    'min_prices' => [
        'USD' => 5000, // discount only applies once the cart reaches $50.00
    ],
],
```

### PercentageOff

```php theme={null}
Lunar\Core\DiscountTypes\PercentageOff
```

Applies a percentage reduction to each eligible cart line.

```php theme={null}
use Lunar\Core\Models\Discount;

Discount::create([
    'name' => '10% Off',
    'handle' => '10_percent_off',
    'type' => \Lunar\Core\DiscountTypes\PercentageOff::class,
    'data' => [
        'percentage' => 10,
    ],
    'starts_at' => now(),
]);
```

| Data field   | Type  | Description                                   |
| :----------- | :---- | :-------------------------------------------- |
| `percentage` | `int` | The percentage to take off each eligible line |

### FixedAmountOff

```php theme={null}
Lunar\Core\DiscountTypes\FixedAmountOff
```

Deducts a fixed, per-currency amount from the cart, distributed proportionally across eligible lines using the price calculator's largest-remainder allocation (so the total discount always exactly matches the configured amount).

```php theme={null}
use Lunar\Core\Models\Discount;

Discount::create([
    'name' => '$5 Off',
    'handle' => '5_dollars_off',
    'type' => \Lunar\Core\DiscountTypes\FixedAmountOff::class,
    'data' => [
        'amounts' => [
            'USD' => 500, // $5.00 off, in minor units
            'EUR' => 450,
        ],
    ],
    'starts_at' => now(),
]);
```

| Data field | Type                 | Description                                            |
| :--------- | :------------------- | :----------------------------------------------------- |
| `amounts`  | `array<string, int>` | The amount to deduct per currency code, in minor units |

`PercentageOff` and `FixedAmountOff` both narrow the cart's eligible lines through `Lunar\Core\DiscountTypes\Concerns\TargetsCartLines`, which applies the discount's collection, brand, and `Discountable` limitations and exclusions before any amount is calculated.

<Info>
  Prior to v2, this was a single `AmountOff` type switched by a `data.fixed_value` boolean. Every stored discount of that type is migrated by the upgrade package to either `PercentageOff` or `FixedAmountOff`, and `data.fixed_values` is renamed to `data.amounts`.
</Info>

### BuyXGetY

```php theme={null}
Lunar\Core\DiscountTypes\BuyXGetY
```

"Buy X, get Y free" promotions. Condition products (the "buy" side) come from `discountableConditions`, reward products (the "get" side) from `discountableRewards`. Both accept products, product variants, or collections.

```php theme={null}
use Lunar\Core\Models\Discount;

$discount = Discount::create([
    'name' => 'Buy 2 Get 1 Free',
    'handle' => 'buy_2_get_1',
    'type' => \Lunar\Core\DiscountTypes\BuyXGetY::class,
    'data' => [
        'min_qty' => 2,
        'reward_qty' => 1,
        'max_reward_qty' => 5,
        'automatically_add_rewards' => false,
    ],
    'starts_at' => now(),
]);

$discount->discountables()->create([
    'discountable_type' => Lunar\Core\Models\Product::morphName(),
    'discountable_id' => $product->id,
    'type' => 'condition',
]);

$discount->discountables()->create([
    'discountable_type' => Lunar\Core\Models\Product::morphName(),
    'discountable_id' => $rewardProduct->id,
    'type' => 'reward',
]);
```

| Data field                  | Type             | Description                                                               |
| :-------------------------- | :--------------- | :------------------------------------------------------------------------ |
| `min_qty`                   | `int`            | Minimum combined quantity of condition items required to trigger a reward |
| `reward_qty`                | `int`            | Reward items granted per qualifying group of `min_qty` condition items    |
| `max_reward_qty`            | `int` `nullable` | Caps the total reward quantity granted                                    |
| `automatically_add_rewards` | `bool`           | Whether qualifying reward items are added to the cart automatically       |

Condition and reward matching also accepts collections: a `Discountable` entry with `discountable_type` set to a collection matches any cart line whose product belongs to that collection.

#### Automatically adding rewards

When `automatically_add_rewards` is `true` and a qualifying cart does not already contain enough reward items, `BuyXGetY` adds a cart line for the reward rather than requiring the customer to add it themselves:

* A reward candidate is chosen at random from the fulfillable `discountableRewards` (a collection reward picks a random fulfillable product from that collection).
* Only reward items that can currently be fulfilled at the required quantity are considered; an out-of-stock reward is skipped rather than raising an error.
* A reward quantity greater than one is added to a single cart line, not one line per unit.
* Added lines are tracked in the line's `meta.added_by_discount` for the discount's ID and quantity.

## The Discounts facade

`Lunar\Core\Facades\Discounts` resolves `Lunar\Core\Contracts\DiscountManager` (bound `scoped`, so a long-lived worker gets a fresh instance per request or job):

```php theme={null}
use Lunar\Core\Facades\Discounts;
```

| Method                                   | Returns           | Description                                                                                                                                             |
| :--------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `channel($channel)`                      | `DiscountManager` | Restrict discount lookups to the given channel(s)                                                                                                       |
| `customerGroup($customerGroups)`         | `DiscountManager` | Restrict discount lookups to the given customer group(s)                                                                                                |
| `getChannels()`                          | `Collection`      | The currently set channels                                                                                                                              |
| `getCustomerGroups()`                    | `Collection`      | The currently set customer groups                                                                                                                       |
| `getDiscounts(?Cart $cart = null)`       | `Collection`      | Active, usable discounts matching the current channel/customer group, narrowed to a cart's lines and coupon code when a cart is given                   |
| `addType(string $classname)`             | `DiscountManager` | Register a custom discount type                                                                                                                         |
| `getTypes()`                             | `Collection`      | All registered discount type instances                                                                                                                  |
| `addApplied(CartDiscount $cartDiscount)` | `DiscountManager` | Record that a discount applied to a cart or cart line                                                                                                   |
| `getApplied()`                           | `Collection`      | The discounts recorded via `addApplied()`                                                                                                               |
| `apply(Cart $cart)`                      | `Cart`            | Runs every eligible discount's `apply()` against the cart, in `priority` order (highest first), stopping early if an applied discount has `stop = true` |
| `resetDiscounts()`                       | `DiscountManager` | Clears the memoized discount list, so the next `apply()`/`getDiscounts()` re-queries                                                                    |
| `validateCoupon(string $coupon)`         | `bool`            | Whether a coupon code matches an active, usable discount                                                                                                |

`getDiscounts()` and `apply()` memoize the resolved discount list per cart state (cart ID, coupon code, customer, and line purchasables). Call `resetDiscounts()` after a change that should re-evaluate eligibility, for example after a coupon code is applied to the cart.

```php theme={null}
Discounts::resetDiscounts();
```

### Registering a custom discount type

```php theme={null}
use Lunar\Core\Facades\Discounts;
use App\DiscountTypes\FreeShippingOverThreshold;

Discounts::addType(FreeShippingOverThreshold::class);
```

Typically called from a service provider's `boot()` method. See [Extending Discounts](/2.x/extending/discounts) for how to build a custom discount type.

### Validating coupons

```php theme={null}
use Lunar\Core\Facades\Discounts;

$isValid = Discounts::validateCoupon('20OFF');
```

The default validator, `Lunar\Core\Validation\CouponValidator`, checks that the coupon matches an active discount under its `max_uses` and, if the discount sets `max_uses_per_user`, that the authenticated user has not exceeded it. Bind a different implementation of `Lunar\Core\Contracts\CouponValidator` in your own service provider to customize this.

## How discounts apply in the cart pipeline

Eligibility is narrowed in two stages. First, `getDiscounts()` pre-filters candidate discounts at the database level: when a cart is given, only discounts whose `Discountable` entries (`condition` or `limitation`) match the cart's products or variants, or whose `collections()`/`brands()` pivot `type` is `condition` and matches a collection or brand on a cart line, are loaded at all. Second, each loaded discount's own `apply()` re-checks the full set of conditions (`checkDiscountConditions()`) and, for `PercentageOff`/`FixedAmountOff`, narrows to eligible lines via `TargetsCartLines` (`limitation`/`exclusion` on collections, brands, and `Discountable` entries).

Discounts are applied as one step in the cart calculation pipeline (`lunar.cart.pipelines.calculate`), after lines and shipping are calculated and before tax:

```
CalculateLines → ApplyShipping → CalculateShippingSubTotal → ApplyDiscounts → CalculateTax → Calculate
```

`Lunar\Core\Pipelines\Cart\ApplyDiscounts` resets `$cart->discounts` and `$cart->discountBreakdown`, then calls `Discounts::apply($cart)`, which runs each eligible discount's type in turn. Each type mutates the affected `CartLine` totals (`discountTotal`, `subTotalDiscounted`) directly and, if it applied, records a `Lunar\Core\ValueObjects\Cart\DiscountBreakdown` describing what happened:

```php theme={null}
Lunar\Core\ValueObjects\Cart\DiscountBreakdown
```

| Property   | Type                                | Description                                                                |
| :--------- | :---------------------------------- | :------------------------------------------------------------------------- |
| `price`    | `PriceValue`                        | Total amount deducted by this discount                                     |
| `lines`    | `Collection<DiscountBreakdownLine>` | The affected cart lines and the quantity of each affected by this discount |
| `discount` | `Discount`                          | The discount that produced this breakdown                                  |

```php theme={null}
Lunar\Core\ValueObjects\Cart\DiscountBreakdownLine
```

| Property   | Type       | Description                                       |
| :--------- | :--------- | :------------------------------------------------ |
| `line`     | `CartLine` | The affected cart line                            |
| `quantity` | `int`      | How many units of that line the discount affected |

Because tax is calculated after discounts, discount amounts reduce the tax-relevant subtotal for the affected lines.

## Custom discount types

A custom discount type extends `Lunar\Core\DiscountTypes\AbstractDiscountType`:

```php theme={null}
<?php

namespace App\DiscountTypes;

use Lunar\Core\DiscountTypes\AbstractDiscountType;
use Lunar\Core\Models\Cart;

class FreeShippingOverThreshold extends AbstractDiscountType
{
    public function getName(): string
    {
        return 'Free Shipping';
    }

    public function apply(Cart $cart): Cart
    {
        if (! $this->checkDiscountConditions($cart)) {
            return $cart;
        }

        // Custom discount logic...

        return $cart;
    }
}
```

This is a brief summary. See [Extending Discounts](/2.x/extending/discounts) for the full guide, including how to target cart lines, build a `DiscountBreakdown`, and register the type.
