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

# Discounts

> Complete reference for the Discount model, types, conditions, and usage in Shopper.

Discounts allow you to create promotional offers for your customers. Shopper supports percentage and fixed amount discounts with flexible application rules, eligibility conditions, and usage limits.

## Models

Shopper uses two models to manage discounts:

| Model                                | Purpose                                                                 |
| ------------------------------------ | ----------------------------------------------------------------------- |
| `Shopper\Core\Models\Discount`       | The discount definition (code, type, value, rules)                      |
| `Shopper\Core\Models\DiscountDetail` | Polymorphic link connecting discounts to specific products or customers |

The Discount model implements `Shopper\Core\Models\Contracts\Discount`. Neither model is configurable via `config/shopper/models.php`.

## Database Schema

### Discount Table

| Column                 | Type      | Nullable | Default | Description                          |
| ---------------------- | --------- | -------- | ------- | ------------------------------------ |
| `id`                   | bigint    | no       | auto    | Primary key                          |
| `code`                 | string    | no       | -       | Unique discount code                 |
| `type`                 | string    | no       | -       | Discount type enum                   |
| `value`                | integer   | no       | -       | Discount value (percentage or cents) |
| `is_active`            | boolean   | no       | false   | Discount visibility                  |
| `apply_to`             | string    | no       | -       | Application scope enum               |
| `min_required`         | string    | no       | -       | Requirement type enum                |
| `min_required_value`   | string    | yes      | null    | Minimum value required               |
| `eligibility`          | string    | no       | -       | Eligibility type enum                |
| `usage_limit`          | integer   | yes      | null    | Maximum total uses                   |
| `usage_limit_per_user` | boolean   | no       | false   | Limit one use per customer           |
| `total_use`            | integer   | no       | 0       | Current total usage count            |
| `start_at`             | datetime  | no       | -       | Discount start date                  |
| `end_at`               | datetime  | yes      | null    | Discount end date (null = no expiry) |
| `zone_id`              | bigint    | yes      | null    | FK to zone (null = all zones)        |
| `metadata`             | json      | yes      | null    | Additional custom data               |
| `created_at`           | timestamp | yes      | null    | Creation timestamp                   |
| `updated_at`           | timestamp | yes      | null    | Last update timestamp                |

### Discountable Table (Pivot)

| Column              | Type    | Nullable | Description                             |
| ------------------- | ------- | -------- | --------------------------------------- |
| `id`                | bigint  | no       | Primary key                             |
| `condition`         | string  | yes      | Condition type (apply\_to, eligibility) |
| `total_use`         | integer | no       | Usage count for this relation           |
| `discountable_id`   | bigint  | no       | Polymorphic relation ID                 |
| `discountable_type` | string  | no       | Polymorphic relation type               |
| `discount_id`       | bigint  | no       | FK to discount                          |

## Discount Type

The `DiscountType` enum defines value types:

```php theme={null}
use Shopper\Core\Enum\DiscountType;

DiscountType::Percentage   // percentage - e.g., 10% off
DiscountType::FixedAmount  // fixed_amount - e.g., $10 off
```

| Type         | Database Value | Value Handling                   |
| ------------ | -------------- | -------------------------------- |
| Percentage   | `percentage`   | Stored as-is (10 = 10%)          |
| Fixed Amount | `fixed_amount` | Stored in cents (1000 = \$10.00) |

## Discount Application

The `DiscountApplyTo` enum defines where the discount applies:

```php theme={null}
use Shopper\Core\Enum\DiscountApplyTo;

DiscountApplyTo::Order     // order - Applies to entire order
DiscountApplyTo::Products  // products - Applies to specific products
```

| Scope    | Database Value | Description                              |
| -------- | -------------- | ---------------------------------------- |
| Order    | `order`        | Discount applies to total order amount   |
| Products | `products`     | Discount applies only to linked products |

## Discount Eligibility

The `DiscountEligibility` enum defines who can use the discount:

```php theme={null}
use Shopper\Core\Enum\DiscountEligibility;

DiscountEligibility::Everyone   // everyone - All customers
DiscountEligibility::Customers  // customers - Specific customers only
```

| Eligibility | Database Value | Description                   |
| ----------- | -------------- | ----------------------------- |
| Everyone    | `everyone`     | Any customer can use          |
| Customers   | `customers`    | Only linked customers can use |

## Discount Requirements

The `DiscountRequirement` enum defines minimum conditions:

```php theme={null}
use Shopper\Core\Enum\DiscountRequirement;

DiscountRequirement::None     // none - No minimum
DiscountRequirement::Price    // price - Minimum order amount
DiscountRequirement::Quantity // quantity - Minimum items count
```

| Requirement | Database Value | min\_required\_value    |
| ----------- | -------------- | ----------------------- |
| None        | `none`         | Not used                |
| Price       | `price`        | Minimum amount in cents |
| Quantity    | `quantity`     | Minimum item count      |

## Discount Condition

The `DiscountCondition` enum is used on the `DiscountDetail` pivot model to distinguish whether a linked record represents a product (the discount applies to) or a customer (who is eligible):

```php theme={null}
use Shopper\Core\Enum\DiscountCondition;

DiscountCondition::ApplyTo      // apply_to - Links a product to the discount
DiscountCondition::Eligibility  // eligibility - Links a customer to the discount
```

## Relationships

### Items (DiscountDetail)

```php theme={null}
use Shopper\Core\Enum\DiscountCondition;

// Get all discount items (products or customers)
$discount->items; // Collection of DiscountDetail models

// Add a product to discount
$discount->items()->create([
    'condition' => DiscountCondition::ApplyTo,
    'discountable_type' => Product::class,
    'discountable_id' => $productId,
]);

// Add eligible customer
$discount->items()->create([
    'condition' => DiscountCondition::Eligibility,
    'discountable_type' => User::class,
    'discountable_id' => $userId,
]);
```

### Zone

```php theme={null}
// Get discount zone (market)
$discount->zone; // Zone model or null

// Restrict discount to a zone
$discount->update(['zone_id' => $zoneId]);
```

## Value Handling

Fixed amount values are stored in cents. Percentage values are stored as the percentage number.

For a \$10 fixed discount, store `1000` (cents). For a 15% percentage discount, store `15`:

```php theme={null}
Discount::query()->create([
    'code' => 'SAVE10',
    'type' => DiscountType::FixedAmount,
    'value' => 1000, // 1000 cents = $10.00
]);

Discount::query()->create([
    'code' => 'SAVE15',
    'type' => DiscountType::Percentage,
    'value' => 15, // 15%
]);
```

## Usage Limit Enforcement

Discounts can cap the total number of redemptions with `usage_limit` and restrict each customer to one redemption with `usage_limit_per_user`. Both limits are enforced with strong consistency guarantees so that a coupon can never be silently over-redeemed under concurrent checkout.

### Global Usage Limit

The `usage_limit` column caps the total number of redemptions across all customers. The `total_use` counter is incremented atomically inside the `CreateOrderFromCartAction` transaction:

1. The discount row is locked with `lockForUpdate` to serialize concurrent checkouts.
2. A conditional `UPDATE` increments `total_use` **only if** it is still below `usage_limit`.
3. If the conditional update affects zero rows, the limit was exhausted between cart validation and commit. `DiscountLimitReachedException::global()` is thrown and the order transaction rolls back, so no order is created.

This compare-and-swap pattern is what fixes the silent over-redemption issue that was patched in v2.8.

### Per-User Limit

When `usage_limit_per_user` is `true`, each customer can redeem the discount at most once. The check counts prior orders on the `orders.discount_id` column (introduced in v2.8), not the legacy `DiscountDetail.total_use` counter:

```php theme={null}
use Shopper\Models\Order;

Order::query()
    ->where('discount_id', $discount->id)
    ->where('customer_id', $cart->customer_id)
    ->exists();
```

The same query runs in two places. `DiscountValidator` rejects the code at cart-apply time so the customer is not surprised at checkout. `CreateOrderFromCartAction` re-checks at commit and throws `DiscountLimitReachedException::perUser()` if the customer redeemed it between cart apply and commit.

A per-user limit is meaningless without an identified customer, so a guest cart (no `customer_id`) can never apply a discount that sets `usage_limit_per_user`. Both `DiscountValidator` and `CreateOrderFromCartAction` reject it with the `discount.requires_login` error rather than letting a guest redeem a once-per-customer code on every anonymous checkout.

### Helpers

```php theme={null}
$discount->hasReachedLimit();

if ($discount->usage_limit !== null) {
    $remaining = $discount->usage_limit - $discount->total_use;
}
```

### Handling DiscountLimitReachedException

Wrap your checkout flow to surface a friendly message to the customer when either limit is hit at commit time:

```php theme={null}
use Shopper\Cart\Actions\CreateOrderFromCartAction;
use Shopper\Cart\Exceptions\DiscountLimitReachedException;

try {
    $order = resolve(CreateOrderFromCartAction::class)->execute($cart);
} catch (DiscountLimitReachedException $e) {
    return back()->with('error', 'This discount can no longer be used.');
}
```

The exception has two static constructors that match the two limit types:

| Constructor                                     | Thrown When                                                               |
| ----------------------------------------------- | ------------------------------------------------------------------------- |
| `DiscountLimitReachedException::global($code)`  | Global `usage_limit` was reached between cart validation and order commit |
| `DiscountLimitReachedException::perUser($code)` | Customer already redeemed a discount with `usage_limit_per_user` set      |

## Creating Discounts

### Percentage Discount for All Orders

```php theme={null}
use Shopper\Core\Models\Discount;
use Shopper\Core\Enum\DiscountType;
use Shopper\Core\Enum\DiscountApplyTo;
use Shopper\Core\Enum\DiscountEligibility;
use Shopper\Core\Enum\DiscountRequirement;

$discount = Discount::query()->create([
    'code' => 'SUMMER20',
    'type' => DiscountType::Percentage,
    'value' => 20, // 20% off
    'is_active' => true,
    'apply_to' => DiscountApplyTo::Order,
    'eligibility' => DiscountEligibility::Everyone,
    'min_required' => DiscountRequirement::None,
    'start_at' => now(),
    'end_at' => now()->addMonth(),
]);
```

### Fixed Amount Discount with Minimum Order

```php theme={null}
$discount = Discount::query()->create([
    'code' => 'SAVE10',
    'type' => DiscountType::FixedAmount,
    'value' => 1000, // 1000 cents = $10 off
    'is_active' => true,
    'apply_to' => DiscountApplyTo::Order,
    'eligibility' => DiscountEligibility::Everyone,
    'min_required' => DiscountRequirement::Price,
    'min_required_value' => '5000', // Minimum 5000 cents = $50 order
    'start_at' => now(),
    'usage_limit' => 100, // Max 100 uses
]);
```

### Product-Specific Discount

```php theme={null}
$discount = Discount::query()->create([
    'code' => 'PRODUCT15',
    'type' => DiscountType::Percentage,
    'value' => 15,
    'is_active' => true,
    'apply_to' => DiscountApplyTo::Products,
    'eligibility' => DiscountEligibility::Everyone,
    'min_required' => DiscountRequirement::None,
    'start_at' => now(),
]);

// Link specific products
$discount->items()->createMany([
    [
        'condition' => 'apply_to',
        'discountable_type' => Product::class,
        'discountable_id' => $product1->id,
    ],
    [
        'condition' => 'apply_to',
        'discountable_type' => Product::class,
        'discountable_id' => $product2->id,
    ],
]);
```

### VIP Customer Discount

```php theme={null}
$discount = Discount::query()->create([
    'code' => 'VIP30',
    'type' => DiscountType::Percentage,
    'value' => 30,
    'is_active' => true,
    'apply_to' => DiscountApplyTo::Order,
    'eligibility' => DiscountEligibility::Customers,
    'min_required' => DiscountRequirement::None,
    'start_at' => now(),
    'usage_limit_per_user' => true, // One use per customer
]);

// Link eligible customers
foreach ($vipCustomers as $customer) {
    $discount->items()->create([
        'condition' => 'eligibility',
        'discountable_type' => User::class,
        'discountable_id' => $customer->id,
    ]);
}
```

## Retrieving Discounts

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

// Get all active discounts
$discounts = Discount::query()
    ->where('is_active', true)
    ->where('start_at', '<=', now())
    ->where(function ($q) {
        $q->whereNull('end_at')
            ->orWhere('end_at', '>', now());
    })
    ->get();

// Get discount by code
$discount = Discount::query()
    ->where('code', 'SUMMER20')
    ->where('is_active', true)
    ->first();

// Get discounts for a zone
$discounts = Discount::query()
    ->where('is_active', true)
    ->where(function ($q) use ($zoneId) {
        $q->whereNull('zone_id')
            ->orWhere('zone_id', $zoneId);
    })
    ->get();

// Get discounts with available uses
$discounts = Discount::query()
    ->where('is_active', true)
    ->where(function ($q) {
        $q->whereNull('usage_limit')
            ->orWhereColumn('total_use', '<', 'usage_limit');
    })
    ->get();
```

## Discount Validation

When a coupon is applied to a cart via `CartManager::applyCoupon()`, the [Cart package](/v2/cart#discount-validation) validates the discount automatically during the calculation pipeline. You do not need to build manual validation logic. The built-in `DiscountValidator` checks all rules (active status, date range, usage limits, eligibility, zone, minimum amounts) and produces clear error messages.

For cases outside the cart pipeline where you need to check a discount programmatically:

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

$discount = Discount::query()
    ->where('code', $code)
    ->where('is_active', true)
    ->first();

if ($discount && ! $discount->hasReachedLimit()) {
    // Discount is valid and has remaining uses
}
```

## Configuration

### Disabling Discounts

If your store doesn't use discount codes, disable the feature in `config/shopper/features.php`:

```php theme={null}
use Shopper\Enum\FeatureState;

return [
    'discount' => FeatureState::Disabled,
];
```

### Permissions

The admin panel generates five permissions for discount management:

| Permission         | Description             |
| ------------------ | ----------------------- |
| `browse_discounts` | View the discounts list |
| `read_discounts`   | View a single discount  |
| `add_discounts`    | Create new discounts    |
| `edit_discounts`   | Edit existing discounts |
| `delete_discounts` | Delete discounts        |

## Components

To customize the admin UI for discount management:

```bash theme={null}
php artisan shopper:component:publish discount
```

Creates `config/shopper/components/discount.php`:

```php theme={null}
use Shopper\Livewire;

return [
    'pages' => [
        'discount-index' => Livewire\Pages\Discount\Index::class,
    ],
    'components' => [
        'slide-overs.discount-form' => Livewire\SlideOvers\DiscountForm::class,
    ],
];
```

## Storefront Example

Applying a discount code on the storefront uses the [Cart package](/v2/cart) API. The `CartManager::applyCoupon()` method validates the code exists, and the calculation pipeline handles the rest (validation rules, discount calculation, and adjustment creation):

```php theme={null}
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Shopper\Cart\CartManager;
use Shopper\Cart\Exceptions\InvalidDiscountException;
use Shopper\Cart\Facades\Cart;

class CouponController extends Controller
{
    public function apply(Request $request)
    {
        $request->validate(['code' => 'required|string']);

        $manager = resolve(CartManager::class);

        try {
            $manager->applyCoupon(Cart::current(), $request->code);
        } catch (InvalidDiscountException $e) {
            return back()->with('error', 'Invalid discount code.');
        }

        return back()->with('success', 'Discount applied.');
    }

    public function remove()
    {
        resolve(CartManager::class)->removeCoupon(Cart::current());

        return back()->with('success', 'Discount removed.');
    }
}
```

## Use Cases

| Scenario                | Type       | Apply To | Eligibility | Requirement |
| ----------------------- | ---------- | -------- | ----------- | ----------- |
| Site-wide sale          | Percentage | Order    | Everyone    | None        |
| Free shipping over \$50 | Fixed      | Order    | Everyone    | Price       |
| Buy 3+ get 10% off      | Percentage | Order    | Everyone    | Quantity    |
| VIP member exclusive    | Percentage | Order    | Customers   | None        |
| Product clearance       | Percentage | Products | Everyone    | None        |
| First order discount    | Fixed      | Order    | Customers   | None        |
