# Occasions & Discount Templates Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add admin-managed occasions with per-unit occasion pricing and owner-managed discount templates that can target multiple buildings/units, then wire both into reservation/invoicing calculations.

**Architecture:** New `occasions`, `occasion_prices`, `discount_templates`, and `discount_template_assignments` tables. A small `PricingService` computes the effective nightly price and applies discounts. `ReservationService` delegates to it and stores the resulting `discount_amount` on reservations so invoices/receipts can mirror it correctly.

**Tech Stack:** Laravel 12, PHP 8.4, Eloquent, Spatie permissions, Pest.

---

## File map

| File | Responsibility |
|---|---|
| `database/migrations/2026_06_30_120000_create_occasions_table.php` | Global occasion labels + approval state |
| `database/migrations/2026_06_30_120001_create_occasion_prices_table.php` | Per-unit nightly occasion price |
| `database/migrations/2026_06_30_120002_create_discount_templates_table.php` | Owner discount rules |
| `database/migrations/2026_06_30_120003_create_discount_template_assignments_table.php` | Polymorphic many-to-many targets |
| `database/migrations/2026_06_30_120004_add_discount_amount_to_reservations_table.php` | Store applied discount total on reservation |
| `app/Models/Occasion.php` | Occasion model + scopes |
| `app/Models/OccasionPrice.php` | Unit/occasion price pivot model |
| `app/Models/DiscountTemplate.php` | Discount rule + assignments |
| `app/Models/DiscountTemplateAssignment.php` | Polymorphic assignment pivot |
| `app/Services/PricingService.php` | Compute effective price, apply occasion + discounts |
| `app/Policies/OccasionPolicy.php` | Admin/owner access |
| `app/Policies/OccasionPricePolicy.php` | Owner-only unit prices |
| `app/Policies/DiscountTemplatePolicy.php` | Owner-only templates |
| `app/Http/Requests/...` | Validation for occasions, prices, templates |
| `app/Http/Controllers/Api/Admin/AdminOccasionController.php` | Admin CRUD + approve/reject |
| `app/Http/Controllers/Api/Owner/OwnerOccasionController.php` | List approved + request new |
| `app/Http/Controllers/Api/Owner/OccasionPriceController.php` | Owner occasion prices |
| `app/Http/Controllers/Api/Owner/DiscountTemplateController.php` | Owner discount templates |
| `app/Http/Resources/...` | JSON resources for new models |
| `app/Services/ReservationService.php` | Integrate PricingService; update invoice/receipt discount |
| `routes/api.php` | Register new routes |
| `config/permissions.php` | Add granular permissions if needed |
| `tests/...` | Feature + unit tests |

---

## Task 1: Create migrations

**Files:**
- Create: `database/migrations/2026_06_30_120000_create_occasions_table.php`
- Create: `database/migrations/2026_06_30_120001_create_occasion_prices_table.php`
- Create: `database/migrations/2026_06_30_120002_create_discount_templates_table.php`
- Create: `database/migrations/2026_06_30_120003_create_discount_template_assignments_table.php`
- Create: `database/migrations/2026_06_30_120004_add_discount_amount_to_reservations_table.php`

- [ ] **Step 1.1: Occasions table**

```php
Schema::create('occasions', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
    $table->foreignId('requested_by_owner_id')->nullable()->constrained('owners')->nullOnDelete();
    $table->foreignId('approved_by_user_id')->nullable()->constrained('users')->nullOnDelete();
    $table->timestamp('approved_at')->nullable();
    $table->text('rejection_reason')->nullable();
    $table->timestamps();
});
```

- [ ] **Step 1.2: Occasion prices table**

```php
Schema::create('occasion_prices', function (Blueprint $table) {
    $table->id();
    $table->foreignId('unit_id')->constrained('units')->cascadeOnDelete();
    $table->foreignId('occasion_id')->constrained('occasions')->cascadeOnDelete();
    $table->decimal('price', 10, 2);
    $table->timestamps();
    $table->unique(['unit_id', 'occasion_id']);
});
```

- [ ] **Step 1.3: Discount templates table**

```php
Schema::create('discount_templates', function (Blueprint $table) {
    $table->id();
    $table->foreignId('owner_id')->constrained('owners')->cascadeOnDelete();
    $table->string('name');
    $table->enum('discount_type', ['fixed', 'percentage']);
    $table->decimal('discount_amount', 10, 2);
    $table->enum('condition_operator', ['=', '>', '<', '>=', '<='])->nullable();
    $table->decimal('condition_amount', 10, 2)->nullable();
    $table->date('start_date')->nullable();
    $table->date('end_date')->nullable();
    $table->unsignedInteger('duration_days')->nullable();
    $table->boolean('is_active')->default(true);
    $table->timestamps();
});
```

- [ ] **Step 1.4: Discount template assignments table**

```php
Schema::create('discount_template_assignments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('discount_template_id')->constrained('discount_templates')->cascadeOnDelete();
    $table->morphs('assignable');
    $table->timestamps();
    $table->unique(['discount_template_id', 'assignable_type', 'assignable_id']);
});
```

- [ ] **Step 1.5: Add discount_amount to reservations**

```php
Schema::table('reservations', function (Blueprint $table) {
    $table->decimal('discount_amount', 10, 2)->default(0)->after('total_price');
});
```

- [ ] **Step 1.6: Run migrations**

Run: `php artisan migrate`
Expected: all new migrations succeed.

---

## Task 2: Create models

**Files:**
- Create: `app/Models/Occasion.php`
- Create: `app/Models/OccasionPrice.php`
- Create: `app/Models/DiscountTemplate.php`
- Create: `app/Models/DiscountTemplateAssignment.php`

- [ ] **Step 2.1: Occasion model**

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Occasion extends Model
{
    use HasFactory;

    protected $fillable = [
        'name', 'slug', 'status',
        'requested_by_owner_id', 'approved_by_user_id', 'approved_at', 'rejection_reason',
    ];

    protected $casts = [
        'approved_at' => 'datetime',
    ];

    public function requestedBy(): BelongsTo
    {
        return $this->belongsTo(Owner::class, 'requested_by_owner_id');
    }

    public function approvedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'approved_by_user_id');
    }

    public function scopeApproved(Builder $query): Builder
    {
        return $query->where('status', 'approved');
    }

    public function scopePending(Builder $query): Builder
    {
        return $query->where('status', 'pending');
    }
}
```

- [ ] **Step 2.2: OccasionPrice model**

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class OccasionPrice extends Model
{
    use HasFactory;

    protected $fillable = ['unit_id', 'occasion_id', 'price'];

    protected $casts = [
        'price' => 'float',
    ];

    public function unit(): BelongsTo
    {
        return $this->belongsTo(Unit::class);
    }

    public function occasion(): BelongsTo
    {
        return $this->belongsTo(Occasion::class);
    }
}
```

- [ ] **Step 2.3: DiscountTemplate model**

```php
<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class DiscountTemplate extends Model
{
    use HasFactory;

    protected $fillable = [
        'owner_id', 'name', 'discount_type', 'discount_amount',
        'condition_operator', 'condition_amount',
        'start_date', 'end_date', 'duration_days', 'is_active',
    ];

    protected $casts = [
        'start_date' => 'date',
        'end_date' => 'date',
        'duration_days' => 'integer',
        'is_active' => 'boolean',
        'discount_amount' => 'float',
        'condition_amount' => 'float',
    ];

    protected static function booted(): void
    {
        static::saving(function (self $template) {
            if ($template->start_date === null && $template->end_date === null && $template->duration_days !== null) {
                $template->start_date = Carbon::today();
                $template->end_date = Carbon::today()->addDays($template->duration_days);
            }
        });
    }

    public function owner(): BelongsTo
    {
        return $this->belongsTo(Owner::class);
    }

    public function buildings(): MorphToMany
    {
        return $this->morphedByMany(Building::class, 'assignable', 'discount_template_assignments');
    }

    public function units(): MorphToMany
    {
        return $this->morphedByMany(Unit::class, 'assignable', 'discount_template_assignments');
    }

    public function assignments()
    {
        return $this->hasMany(DiscountTemplateAssignment::class);
    }

    public function isActiveForDate(Carbon $date): bool
    {
        if (! $this->is_active) {
            return false;
        }

        $start = $this->start_date;
        $end = $this->end_date;

        if ($start === null && $end === null) {
            return true;
        }

        return ($start === null || ! $date->startOfDay()->lt($start))
            && ($end === null || ! $date->startOfDay()->gt($end));
    }

    public function isActiveForRange(Carbon $checkIn, Carbon $checkOut): bool
    {
        $date = $checkIn->copy();
        while ($date->lte($checkOut)) {
            if ($this->isActiveForDate($date)) {
                return true;
            }
            $date->addDay();
        }

        return false;
    }

    public function conditionMatches(float $subtotal): bool
    {
        if ($this->condition_operator === null || $this->condition_amount === null) {
            return true;
        }

        return match ($this->condition_operator) {
            '=' => $subtotal == $this->condition_amount,
            '>' => $subtotal > $this->condition_amount,
            '<' => $subtotal < $this->condition_amount,
            '>=' => $subtotal >= $this->condition_amount,
            '<=' => $subtotal <= $this->condition_amount,
            default => false,
        };
    }

    public function apply(float $amount): float
    {
        $discount = $this->discount_type === 'percentage'
            ? $amount * ($this->discount_amount / 100)
            : $this->discount_amount;

        return max(0, $amount - $discount);
    }
}
```

- [ ] **Step 2.4: DiscountTemplateAssignment model**

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class DiscountTemplateAssignment extends Model
{
    use HasFactory;

    protected $fillable = ['discount_template_id', 'assignable_type', 'assignable_id'];

    public function discountTemplate(): BelongsTo
    {
        return $this->belongsTo(DiscountTemplate::class);
    }

    public function assignable(): MorphTo
    {
        return $this->morphTo();
    }
}
```

- [ ] **Step 2.5: Update Unit model relations**

Modify `app/Models/Unit.php`:

```php
public function occasionPrices(): HasMany
{
    return $this->hasMany(OccasionPrice::class);
}

public function discountTemplates(): MorphToMany
{
    return $this->morphedByMany(DiscountTemplate::class, 'assignable', 'discount_template_assignments');
}
```

Also add `use Illuminate\Database\Eloquent\Relations\MorphToMany;` if not present.

- [ ] **Step 2.6: Update Building model relations**

Modify `app/Models/Building.php`:

```php
public function discountTemplates(): MorphToMany
{
    return $this->morphedByMany(DiscountTemplate::class, 'assignable', 'discount_template_assignments');
}
```

- [ ] **Step 2.7: Verify models load**

Run: `php artisan tinker --execute="echo App\Models\Occasion::class; echo App\Models\DiscountTemplate::class;"`
Expected: class names printed without errors.

---

## Task 3: Create PricingService

**Files:**
- Create: `app/Services/PricingService.php`

- [ ] **Step 3.1: PricingService implementation**

```php
<?php

namespace App\Services;

use App\Models\DiscountTemplate;
use App\Models\Occasion;
use App\Models\Unit;
use Carbon\Carbon;

class PricingService
{
    /**
     * Compute the reservation subtotal after occasion price and discount templates.
     *
     * @return array{subtotal: float, discount_amount: float, occasion_price: float|null}
     */
    public function calculateSubtotal(
        Unit $unit,
        Carbon $checkIn,
        Carbon $checkOut,
        ?Occasion $occasion = null,
    ): array {
        $nights = count(UnitAvailabilityService::dateRange($checkIn->format('Y-m-d'), $checkOut->format('Y-m-d')));
        $nightlyPrice = $unit->offer_price ?? $unit->base_price;
        $occasionPrice = null;

        if ($occasion) {
            $occasionPriceModel = $unit->occasionPrices()
                ->where('occasion_id', $occasion->id)
                ->first();

            if ($occasionPriceModel) {
                $nightlyPrice = $occasionPriceModel->price;
                $occasionPrice = $nightlyPrice;
            }
        }

        $subtotal = $nights * $nightlyPrice;
        $discountAmount = 0.00;

        $templates = $this->matchingDiscountTemplates($unit, $checkIn, $checkOut, $subtotal);

        foreach ($templates as $template) {
            $before = $subtotal;
            $subtotal = $template->apply($subtotal);
            $discountAmount += ($before - $subtotal);
        }

        // Cap discount so net never goes negative.
        $originalSubtotal = $nights * ($occasionPrice ?? ($unit->offer_price ?? $unit->base_price));
        if ($discountAmount > $originalSubtotal) {
            $discountAmount = $originalSubtotal;
            $subtotal = 0;
        }

        return [
            'subtotal' => round($subtotal, 2),
            'discount_amount' => round($discountAmount, 2),
            'occasion_price' => $occasionPrice,
        ];
    }

    /**
     * @return array<int, DiscountTemplate>
     */
    public function matchingDiscountTemplates(Unit $unit, Carbon $checkIn, Carbon $checkOut, float $subtotal): array
    {
        $templates = DiscountTemplate::query()
            ->where('is_active', true)
            ->where('owner_id', $unit->building->owner_id)
            ->where(function ($q) use ($unit) {
                $q->whereHas('assignments', fn ($a) => $a
                    ->where('assignable_type', Unit::class)
                    ->where('assignable_id', $unit->id))
                ->orWhereHas('assignments', fn ($a) => $a
                    ->where('assignable_type', Building::class)
                    ->where('assignable_id', $unit->building_id));
            })
            ->get();

        return $templates
            ->filter(fn (DiscountTemplate $t) => $t->isActiveForRange($checkIn, $checkOut))
            ->filter(fn (DiscountTemplate $t) => $t->conditionMatches($subtotal))
            ->all();
    }
}
```

- [ ] **Step 3.2: Add facade**

Create `app/Facades/PricingService.php`:

```php
<?php

namespace App\Facades;

use App\Services\PricingService as PricingServiceClass;
use Illuminate\Support\Facades\Facade;

/**
 * @see \App\Services\PricingService
 */
class PricingService extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return PricingServiceClass::class;
    }
}
```

---

## Task 4: Update ReservationService pricing

**Files:**
- Modify: `app/Services/ReservationService.php`

- [ ] **Step 4.1: Replace calculateTotal to use PricingService**

Change signature and body:

```php
public function calculateTotal(
    int $unitId,
    string $checkIn,
    string $checkOut,
    ?PromoCode $promoCode = null,
    ?Occasion $occasion = null,
): float {
    $unit = Unit::findOrFail($unitId);
    $checkInDate = Carbon::parse($checkIn);
    $checkOutDate = Carbon::parse($checkOut);

    $result = PricingService::calculateSubtotal($unit, $checkInDate, $checkOutDate, $occasion);
    $total = $result['subtotal'];

    if ($promoCode) {
        $total = $this->applyPromoCodeDiscount($total, $promoCode);
    }

    return round($total, 2);
}
```

Add helper if not present:

```php
private function applyPromoCodeDiscount(float $total, PromoCode $promoCode): float
{
    $discount = $promoCode->discount_type === 'percentage'
        ? $total * ($promoCode->discount_value / 100)
        : $promoCode->discount_value;

    return round(max(0, $total - min($discount, $total)), 2);
}
```

- [ ] **Step 4.2: Update persistReservation to accept occasion**

Inside `persistReservation`, read `occasion_id` from `$data` and resolve the occasion:

```php
$occasion = null;
if (! empty($data['occasion_id'])) {
    $occasion = Occasion::approved()->findOrFail($data['occasion_id']);
}
```

Pass it to `calculateTotal`:

```php
'total_price' => $this->calculateTotal($unitId, $data['check_in_date'], $data['check_out_date'], $promoCode, $occasion),
```

Also unset `occasion_id` before creating the reservation:

```php
unset($data['promo_code'], $data['photo'], $data['name'], $data['phone'], $data['whatsapp_number'], $data['otp'], $data['occasion_id']);
```

- [ ] **Step 4.3: Store discount_amount on the reservation**

When creating the reservation, compute the pricing result once and reuse it:

```php
$pricing = PricingService::calculateSubtotal($unit, Carbon::parse($data['check_in_date']), Carbon::parse($data['check_out_date']), $occasion);

$totalPrice = $pricing['subtotal'];
if ($promoCode) {
    $totalPrice = $this->applyPromoCodeDiscount($totalPrice, $promoCode);
}

$reservation = Reservation::create(array_merge($data, [
    'reservation_number' => $this->reservationNumber(),
    'customer_type' => $customerType,
    'customer_id' => $customerId,
    'total_price' => $totalPrice,
    'discount_amount' => $pricing['discount_amount'],
    'promo_code_id' => $promoCode?->id,
    'source' => 'online',
]));
```

Replace the existing inline `calculateTotal` call.

- [ ] **Step 4.4: Update recalculateReservation**

Find the recalculation logic and ensure it recomputes `total_price` and `discount_amount` using `PricingService` with the reservation's `occasion_id` if any.

```php
$occasion = $reservation->occasion_id ? Occasion::find($reservation->occasion_id) : null;
$pricing = PricingService::calculateSubtotal(
    $reservation->unit,
    $reservation->check_in_date,
    $reservation->check_out_date,
    $occasion
);

$totalPrice = $pricing['subtotal'];
if ($reservation->promoCode) {
    $totalPrice = $this->applyPromoCodeDiscount($totalPrice, $reservation->promoCode);
}

$reservation->update([
    'total_price' => $totalPrice,
    'discount_amount' => $pricing['discount_amount'],
]);
```

Then mirror the new `discount_amount` to invoices/receipts when recalculating.

- [ ] **Step 4.5: Update createDocument**

Use `reservation->discount_amount` when creating owner invoice and customer receipt:

```php
$discount = $reservation->discount_amount ?? 0.00;
$netTotal = $total - $discount;
```

Set `discount` on both invoice and receipt records to `$discount`.

- [ ] **Step 4.6: Add occasion relation to Reservation model**

Modify `app/Models/Reservation.php`:

```php
public function occasion(): BelongsTo
{
    return $this->belongsTo(Occasion::class);
}
```

Add `occasion_id` to `$fillable`.

---

## Task 5: Validation requests

**Files:**
- Create: `app/Http/Requests/Admin/StoreOccasionRequest.php`
- Create: `app/Http/Requests/Admin/UpdateOccasionRequest.php`
- Create: `app/Http/Requests/Owner/StoreOccasionRequest.php`
- Create: `app/Http/Requests/Owner/StoreOccasionPriceRequest.php`
- Create: `app/Http/Requests/Owner/UpdateOccasionPriceRequest.php`
- Create: `app/Http/Requests/Owner/StoreDiscountTemplateRequest.php`
- Create: `app/Http/Requests/Owner/UpdateDiscountTemplateRequest.php`

- [ ] **Step 5.1: Admin occasion request**

```php
<?php

namespace App\Http\Requests\Admin;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreOccasionRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'slug' => ['required', 'string', 'max:255', 'unique:occasions,slug'],
            'status' => ['required', 'string', Rule::in(['pending', 'approved', 'rejected'])],
        ];
    }
}
```

`UpdateOccasionRequest` is similar with `Rule::unique('occasions', 'slug')->ignore($this->occasion)`.

- [ ] **Step 5.2: Owner occasion request**

```php
<?php

namespace App\Http\Requests\Owner;

use Illuminate\Foundation\Http\FormRequest;

class StoreOccasionRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
        ];
    }
}
```

- [ ] **Step 5.3: Occasion price request**

```php
<?php

namespace App\Http\Requests\Owner;

use Illuminate\Foundation\Http\FormRequest;

class StoreOccasionPriceRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'unit_id' => ['required', 'exists:units,id'],
            'occasion_id' => ['required', 'exists:occasions,id'],
            'price' => ['required', 'numeric', 'min:0'],
        ];
    }
}
```

`UpdateOccasionPriceRequest` only requires `price`.

- [ ] **Step 5.4: Discount template request**

```php
<?php

namespace App\Http\Requests\Owner;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreDiscountTemplateRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'discount_type' => ['required', 'string', Rule::in(['fixed', 'percentage'])],
            'discount_amount' => ['required', 'numeric', 'min:0'],
            'condition_operator' => ['nullable', 'string', Rule::in(['=', '>', '<', '>=', '<='])],
            'condition_amount' => ['nullable', 'numeric', 'min:0', 'required_with:condition_operator'],
            'start_date' => ['nullable', 'date', 'required_with:end_date'],
            'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
            'duration_days' => ['nullable', 'integer', 'min:1'],
            'assignments' => ['nullable', 'array'],
            'assignments.*.type' => ['required_with:assignments', 'string', Rule::in(['building', 'unit'])],
            'assignments.*.id' => ['required_with:assignments', 'integer'],
        ];
    }
}
```

`UpdateDiscountTemplateRequest` is the same but all fields optional.

---

## Task 6: Policies

**Files:**
- Create: `app/Policies/OccasionPolicy.php`
- Create: `app/Policies/OccasionPricePolicy.php`
- Create: `app/Policies/DiscountTemplatePolicy.php`

- [ ] **Step 6.1: OccasionPolicy**

```php
<?php

namespace App\Policies;

use App\Models\Occasion;
use App\Models\User;

class OccasionPolicy
{
    public function before(User $user, string $ability): ?bool
    {
        return $user->isSuperAdmin() ? true : null;
    }

    public function viewAny(User $user): bool
    {
        return $user->isAdmin() || $user->isApprovedOwner() || $user->isActiveEmployee();
    }

    public function view(User $user, Occasion $occasion): bool
    {
        return $user->isAdmin() || $user->isApprovedOwner() || $user->isActiveEmployee();
    }

    public function create(User $user): bool
    {
        return $user->isAdmin();
    }

    public function update(User $user, Occasion $occasion): bool
    {
        return $user->isAdmin();
    }

    public function delete(User $user, Occasion $occasion): bool
    {
        return $user->isAdmin();
    }

    public function request(User $user): bool
    {
        return $user->isApprovedOwner();
    }

    public function approve(User $user, Occasion $occasion): bool
    {
        return $user->isAdmin();
    }
}
```

- [ ] **Step 6.2: OccasionPricePolicy**

```php
<?php

namespace App\Policies;

use App\Models\OccasionPrice;
use App\Models\User;

class OccasionPricePolicy
{
    public function before(User $user, string $ability): ?bool
    {
        return $user->isSuperAdmin() ? true : null;
    }

    public function viewAny(User $user): bool
    {
        return $user->isApprovedOwner() || $user->isActiveEmployee();
    }

    public function view(User $user, OccasionPrice $price): bool
    {
        return $this->ownsUnit($user, $price->unit);
    }

    public function create(User $user): bool
    {
        return $user->isApprovedOwner() || $user->isActiveEmployee();
    }

    public function update(User $user, OccasionPrice $price): bool
    {
        return $this->ownsUnit($user, $price->unit);
    }

    public function delete(User $user, OccasionPrice $price): bool
    {
        return $this->ownsUnit($user, $price->unit);
    }

    private function ownsUnit(User $user, $unit): bool
    {
        $ownerId = $unit->building->owner_id;

        return $user->owner?->id === $ownerId
            || $user->employee?->owner_id === $ownerId;
    }
}
```

- [ ] **Step 6.3: DiscountTemplatePolicy**

```php
<?php

namespace App\Policies;

use App\Models\DiscountTemplate;
use App\Models\User;

class DiscountTemplatePolicy
{
    public function before(User $user, string $ability): ?bool
    {
        return $user->isSuperAdmin() ? true : null;
    }

    public function viewAny(User $user): bool
    {
        return $user->isApprovedOwner() || $user->isActiveEmployee();
    }

    public function view(User $user, DiscountTemplate $template): bool
    {
        return $this->owns($user, $template);
    }

    public function create(User $user): bool
    {
        return $user->isApprovedOwner() || $user->isActiveEmployee();
    }

    public function update(User $user, DiscountTemplate $template): bool
    {
        return $this->owns($user, $template);
    }

    public function delete(User $user, DiscountTemplate $template): bool
    {
        return $this->owns($user, $template);
    }

    private function owns(User $user, DiscountTemplate $template): bool
    {
        $ownerId = $template->owner_id;

        return $user->owner?->id === $ownerId
            || $user->employee?->owner_id === $ownerId;
    }
}
```

---

## Task 7: Controllers

**Files:**
- Create: `app/Http/Controllers/Api/Admin/AdminOccasionController.php`
- Create: `app/Http/Controllers/Api/Owner/OwnerOccasionController.php`
- Create: `app/Http/Controllers/Api/Owner/OccasionPriceController.php`
- Create: `app/Http/Controllers/Api/Owner/DiscountTemplateController.php`
- Create resources as needed

- [ ] **Step 7.1: AdminOccasionController**

Standard resource controller for `Occasion` with `approve`/`reject` actions.

```php
public function approve(Occasion $occasion)
{
    $this->authorize('approve', $occasion);
    $occasion->update([
        'status' => 'approved',
        'approved_by_user_id' => auth()->id(),
        'approved_at' => now(),
        'rejection_reason' => null,
    ]);
    return response()->json(['message' => 'Occasion approved.']);
}

public function reject(Request $request, Occasion $occasion)
{
    $this->authorize('approve', $occasion);
    $request->validate(['rejection_reason' => ['nullable', 'string', 'max:1000']]);
    $occasion->update([
        'status' => 'rejected',
        'approved_by_user_id' => auth()->id(),
        'approved_at' => now(),
        'rejection_reason' => $request->input('rejection_reason'),
    ]);
    return response()->json(['message' => 'Occasion rejected.']);
}
```

- [ ] **Step 7.2: OwnerOccasionController**

```php
public function index()
{
    $this->authorize('viewAny', Occasion::class);
    return OccasionResource::collection(Occasion::approved()->get());
}

public function store(StoreOccasionRequest $request)
{
    $this->authorize('request', Occasion::class);
    $occasion = Occasion::create([
        'name' => $request->validated('name'),
        'slug' => Str::slug($request->validated('name')).'-'.Str::random(6),
        'status' => 'pending',
        'requested_by_owner_id' => auth()->user()->owner->id,
    ]);
    return OccasionResource::make($occasion)->response()->setStatusCode(201);
}
```

- [ ] **Step 7.3: OccasionPriceController**

Standard owner-scoped CRUD. Enforce the unit belongs to the owner.

- [ ] **Step 7.4: DiscountTemplateController**

Standard owner-scoped CRUD. On store/update, sync assignments:

```php
private function syncAssignments(DiscountTemplate $template, array $assignments): void
{
    $template->assignments()->delete();
    foreach ($assignments as $item) {
        $modelClass = $item['type'] === 'building' ? Building::class : Unit::class;
        $template->assignments()->create([
            'assignable_type' => $modelClass,
            'assignable_id' => $item['id'],
        ]);
    }
}
```

---

## Task 8: Routes

**Files:**
- Modify: `routes/api.php`

- [ ] **Step 8.1: Register routes**

Inside the admin routes group:

```php
Route::apiResource('occasions', AdminOccasionController::class);
Route::post('occasions/{occasion}/approve', [AdminOccasionController::class, 'approve'])->name('admin.occasions.approve');
Route::post('occasions/{occasion}/reject', [AdminOccasionController::class, 'reject'])->name('admin.occasions.reject');
```

Inside the authenticated routes group:

```php
Route::get('occasions', [OwnerOccasionController::class, 'index'])->name('occasions.index');
Route::post('occasion-requests', [OwnerOccasionController::class, 'store'])->name('occasions.request');
Route::apiResource('occasion-prices', OccasionPriceController::class);
Route::apiResource('discount-templates', DiscountTemplateController::class);
Route::post('discount-templates/{discount_template}/toggle', [DiscountTemplateController::class, 'toggle'])->name('discount-templates.toggle');
```

---

## Task 9: Tests

**Files:**
- Create: `tests/Feature/Admin/OccasionManagementTest.php`
- Create: `tests/Feature/Owner/OccasionPriceTest.php`
- Create: `tests/Feature/Owner/DiscountTemplateTest.php`
- Create: `tests/Unit/Services/PricingServiceTest.php`

- [ ] **Step 9.1: PricingService unit tests**

Test:
- Base price is used without occasion/discount.
- Occasion price overrides base/offer price.
- Discount template applies when date window overlaps.
- Condition on reservation total controls application.
- Multiple templates capped at 100%.
- Promo code applies after templates.

- [ ] **Step 9.2: Feature tests**

- Admin CRUD + approve/reject occasion request.
- Owner requests occasion.
- Owner sets occasion price and reservation uses it.
- Owner creates discount template, assigns to building/unit, reservation reflects discount.
- Invoices/receipts include correct `discount`.

---

## Task 10: Run full test suite

- [ ] **Step 10.1: Run tests**

Run: `php artisan test --compact`
Expected: all existing + new tests pass.

- [ ] **Step 10.2: Run Pint**

Run: `php vendor/bin/pint`
Expected: no style issues.
