# Promotion / Discount Refactor Handoff

## Objective

Build discounting as a production-grade capability owned by the domain-oriented
`Promotion` module. Do not copy the legacy implementation from `Admin`, and do
not move discount business logic into `Shared`.

```text
Catalog  ──────> Promotion public contracts
Customer ──────> Promotion public contracts
Promotion ────X> Catalog, Customer, Admin
```

`Discount` is the first Promotion capability. Campaigns, coupons, eligibility
rules, stacking policies, and schedules can later live in this module.

## Working rules

- Continue one step at a time: explain, let the developer implement, review, and
  only then continue.
- Do not edit files unless the developer explicitly asks.
- Do not copy the old Admin model, trait, observer, repositories, or rules.
- Keep PHP classes under `Modules/Promotion/app`; module Composer maps
  `Modules\\Promotion\\` to `app/`.
- Prefer capability-specific APIs over generic CRUD repositories.
- Do not add a Repository unless real query complexity justifies it.
- Do not run the new migration while the Admin migration still creates the same
  `discounts` table.
- Run Pint and module tests before committing.

## Current state

The module scaffold was cleaned. Current intended files:

```text
Modules/Promotion/
├── app/Providers/PromotionServiceProvider.php
├── composer.json
├── database/migrations/2026_07_15_164719_create_discounts_table.php
└── module.json
```

`PromotionServiceProvider` only loads module migrations. The module is enabled
in `modules_statuses.json`.

The new schema uses:

```text
id
discountable_type
discountable_id
percentage
starts_at nullable
ends_at nullable
is_enabled
created_at
updated_at
```

It has a unique owner constraint and an availability index. Legacy vocabulary is
intentionally replaced:

```text
discount_percent     -> percentage
start_date           -> starts_at
end_date             -> ends_at
is_visible           -> is_enabled
has_date_dependency  -> removed; nullable boundaries express the rule
```

## Immediate next action

Finish migration formatting:

```php
declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
```

and:

```php
Schema::create('discounts', function (Blueprint $table): void {
```

Then run:

```powershell
vendor/bin/pint Modules/Promotion/database/migrations/2026_07_15_164719_create_discounts_table.php
```

Next, create `Modules/Promotion/app/Models/Discount.php`.

## Target structure

```text
Modules/Promotion/
├── app/
│   ├── Concerns/HasDiscount.php
│   ├── Contracts/DiscountOwner.php
│   ├── DTOs/DiscountDTO.php
│   ├── Http/Resources/DiscountResource.php
│   ├── Models/Discount.php
│   ├── Providers/PromotionServiceProvider.php
│   ├── Services/Contracts/DiscountServiceContract.php
│   ├── Services/DiscountService.php
│   └── Validation/DiscountRules.php
├── database/migrations/*_create_discounts_table.php
└── tests/
    ├── Feature/DiscountServiceTest.php
    └── Unit/
        ├── DiscountTest.php
        └── DiscountRulesTest.php
```

## Implementation sequence

### 1. Model

Create a new model rather than copying `Modules\\Admin\\Models\\Discount`.

- Fillable: business fields only; never morph owner columns.
- Cast `percentage` to integer.
- Cast boundaries to `immutable_datetime`.
- Cast `is_enabled` to boolean.
- Define `discountable(): MorphTo`.
- Define deterministic `isActiveAt(CarbonInterface $moment)`.
- Define `isCurrentlyActive()` by delegating to `isActiveAt(now())`.
- Define a typed `scopeActiveAt()` matching the in-memory rule.

Active means:

```text
is_enabled = true
percentage > 0
starts_at is null OR starts_at <= moment
ends_at is null OR ends_at >= moment
```

Time boundaries are inclusive.

### 2. DTO

Move ownership away from:

```text
Modules/Shared/app/DTOs/Discount/DiscountDTO.php
```

Create `Modules/Promotion/app/DTOs/DiscountDTO.php` with `percentage`,
`startsAt`, `endsAt`, and `isEnabled`. Do not retain `hasDateDependency`.
Define and test UTC parsing/persistence policy.

Omitted `discount` during PATCH means no change. An explicitly empty/null
discount may mean removal. The caller must preserve this distinction.

### 3. Owner integration

Create:

```text
Modules/Promotion/app/Contracts/DiscountOwner.php
Modules/Promotion/app/Concerns/HasDiscount.php
```

The interface provides service type safety; the concern provides the reusable
Eloquent `MorphOne`. Both are intentional because traits cannot be parameter
types. The concern should clean up on real/force deletion while preserving data
during soft deletion.

Query-builder bulk deletes bypass model events. Consumer modules must preserve
lifecycle events or invoke cleanup explicitly.

### 4. Service

Create `DiscountServiceContract` and `DiscountService` with narrow operations:

```text
sync(owner, DTO): void
delete(owner): void
```

Use the owner's `MorphOne::updateOrCreate()` directly. Do not introduce a generic
BaseRepository just to wrap one relationship query. Bind the stateless service in
`PromotionServiceProvider`.

### 5. Validation

Create reusable, configurable-prefix `DiscountRules`:

- optional/nullable discount object;
- percentage integer between 1 and 100 when supplied;
- valid starts_at and ends_at;
- start before end and end after start;
- boolean is_enabled.

### 6. Resource

Create `app/Http/Resources/DiscountResource.php`. `JsonResource` belongs under
HTTP because it is presentation-specific. Return stable new field names.

### 7. Tests

Required coverage:

- unbounded active discount;
- disabled and zero-percent discounts inactive;
- future and expired discounts inactive;
- exact start/end boundaries active;
- SQL scope matches in-memory behavior;
- service create/update without duplicates;
- explicit removal behavior;
- owner deletion cleanup;
- service-container binding;
- valid/invalid percentage and date validation;
- custom validation prefix.

Run:

```powershell
vendor/bin/pint Modules/Promotion
php artisan test Modules/Promotion/tests
```

## Cutover plan — do not perform early

The Admin migration currently creates the same table. Once the new capability
and consumers are tested:

1. Determine whether deployed environments contain legacy discount data.
2. If data exists, use a transition migration; do not edit a deployed migration.
3. Map legacy columns to the new schema.
4. When `has_date_dependency` is false, migrate boundaries as null.
5. Establish stable Laravel morph-map aliases before changing owner namespaces.
6. Update Category/Product/Brand/Variant to consume Promotion contracts.
7. Move price calculation from Customer to Promotion/Pricing. Never use floats
   for money; use integer minor units or a Money value object.
8. Only then remove old Admin model, trait, observers, rules, repositories,
   service methods, and migration.
9. Verify fresh-install and upgrade migration paths separately, then run the full
   project test suite.

## Related completed work

The separate Seo module was completed and pushed in:

```text
b4b5881 feat(seo): add modular SEO metadata capability
```

Use its boundary, provider, and testing patterns as guidance, but do not copy it
mechanically: Promotion has temporal rules and eventual monetary concerns.
