# Occasion Date-Split Pricing 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:** Make reservation pricing treat only the dates that match an occasion as occasion-priced, while the remaining dates use the normal unit price; sum both parts and continue applying discount templates to the total.

**Architecture:** Add date metadata (`start_date`, `end_date`, `days_of_week`) to the `occasions` table and model so an occasion can describe its own days. Extend `Occasion` with helpers to test whether a single date or a reservation range is an occasion date. Update `PricingService::calculateSubtotal` to iterate the reservation nights, count normal vs. occasion nights, price each bucket separately, and sum them before applying discount templates. Keep `ReservationService` invoice/receipt line items consistent by mirroring the effective nightly price and total.

**Tech Stack:** PHP 8.3, Laravel 11, Pest PHP, Carbon, MySQL/SQLite.

---

## File Structure

| File | Responsibility |
|---|---|
| `database/migrations/2026_07_03_000000_add_date_fields_to_occasions_table.php` | Adds `start_date`, `end_date`, `days_of_week` columns to `occasions`. |
| `app/Models/Occasion.php` | Casts new columns; adds `isOccasionDate()` and `splitRange()` helpers. |
| `app/Services/PricingService.php` | Splits reservation nights into normal/occasion buckets and sums their prices. |
| `app/Services/ReservationService.php` | Uses `PricingService` results when creating documents and recalculating; removes the duplicated base-price calculation. |
| `tests/Unit/Services/PricingServiceTest.php` | Adds tests for partial occasion coverage, weekend recurring occasions, and the existing `$nights` undefined bug. |
| `occasions_prices_discounts.md` | Updates the pricing engine section to document per-date occasion pricing. |

---

## Task 1: Add occasion date metadata (schema + model)

**Files:**
- Create: `database/migrations/2026_07_03_000000_add_date_fields_to_occasions_table.php`
- Modify: `app/Models/Occasion.php`

### Step 1.1: Create migration

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('occasions', function (Blueprint $table) {
            $table->date('start_date')->nullable()->after('status');
            $table->date('end_date')->nullable()->after('start_date');
            $table->json('days_of_week')->nullable()->after('end_date');
        });
    }

    public function down(): void
    {
        Schema::table('occasions', function (Blueprint $table) {
            $table->dropColumn(['start_date', 'end_date', 'days_of_week']);
        });
    }
};
```

### Step 1.2: Update Occasion model

Modify `app/Models/Occasion.php` to add casts, fillable, and helpers.

Add at the top of the class:

```php
protected $fillable = [
    'name', 'slug', 'status',
    'requested_by_owner_id', 'approved_by_user_id', 'approved_at', 'rejection_reason',
    'start_date', 'end_date', 'days_of_week',
];

protected $casts = [
    'start_date' => 'date',
    'end_date' => 'date',
    'days_of_week' => 'array',
    'approved_at' => 'datetime',
];
```

Add methods before the closing brace:

```php
/**
 * Determine whether a given date is covered by this occasion.
 */
public function isOccasionDate(Carbon $date): bool
{
    if ($this->status !== 'approved') {
        return false;
    }

    $start = $this->start_date;
    $end = $this->end_date;
    $days = $this->days_of_week ?? [];

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

    $matchesDayOfWeek = $days === [] || in_array($date->dayOfWeek, $days, true);

    return $inDateRange && $matchesDayOfWeek;
}

/**
 * Split a reservation range into normal and occasion night counts.
 *
 * @return array{normal: int, occasion: int}
 */
public function splitRange(Carbon $checkIn, Carbon $checkOut): array
{
    $normal = 0;
    $occasion = 0;

    $date = $checkIn->copy();
    while ($date->lt($checkOut)) {
        if ($this->isOccasionDate($date)) {
            $occasion++;
        } else {
            $normal++;
        }
        $date->addDay();
    }

    return ['normal' => $normal, 'occasion' => $occasion];
}
```

Add the Carbon import:

```php
use Carbon\Carbon;
```

---

## Task 2: Write failing pricing tests

**Files:**
- Modify: `tests/Unit/Services/PricingServiceTest.php`

### Step 2.1: Add tests

Append to the end of the test file (before the final closing brace):

```php
it('prices only the nights that fall inside the occasion date range', function () {
    $occasion = Occasion::create([
        'name' => 'Eid',
        'slug' => 'eid',
        'status' => 'approved',
        'start_date' => '2026-08-02',
        'end_date' => '2026-08-03',
    ]);

    OccasionPrice::create([
        'unit_id' => $this->unit->id,
        'occasion_id' => $occasion->id,
        'price' => 150,
    ]);

    // 2026-08-01 (normal), 02 (occasion), 03 (occasion) => 3 nights
    $result = $this->service->calculateSubtotal(
        $this->unit,
        Carbon::parse('2026-08-01'),
        Carbon::parse('2026-08-04'),
        $occasion
    );

    expect($result['subtotal'])->toEqual(400.0) // 100 + 150 + 150
        ->and($result['occasion_price'])->toEqual(150.0);
});

it('prices only the nights that match the recurring occasion days', function () {
    $occasion = Occasion::create([
        'name' => 'Weekend',
        'slug' => 'weekend',
        'status' => 'approved',
        'days_of_week' => [5, 6], // Friday, Saturday
    ]);

    OccasionPrice::create([
        'unit_id' => $this->unit->id,
        'occasion_id' => $occasion->id,
        'price' => 120,
    ]);

    // 2026-08-03 Monday -> 2026-08-09 Sunday
    // Occasion nights: 2026-08-07 (Fri), 2026-08-08 (Sat) => 2 nights
    $result = $this->service->calculateSubtotal(
        $this->unit,
        Carbon::parse('2026-08-03'),
        Carbon::parse('2026-08-09'),
        $occasion
    );

    expect($result['subtotal'])->toEqual(640.0) // 5 * 100 + 2 * 120
        ->and($result['occasion_price'])->toEqual(120.0);
});

it('falls back to normal price when no occasion price is set even if occasion matches', function () {
    $occasion = Occasion::create([
        'name' => 'Eid',
        'slug' => 'eid-no-price',
        'status' => 'approved',
        'start_date' => '2026-08-01',
        'end_date' => '2026-08-31',
    ]);

    $result = $this->service->calculateSubtotal(
        $this->unit,
        Carbon::parse('2026-08-01'),
        Carbon::parse('2026-08-04'),
        $occasion
    );

    expect($result['subtotal'])->toEqual(300.0)
        ->and($result['occasion_price'])->toBeNull();
});
```

### Step 2.2: Run tests and confirm failure

```bash
vendor/bin/pest tests/Unit/Services/PricingServiceTest.php
```

Expected: FAIL. New tests fail because `PricingService` does not split normal/occasion nights.

---

## Task 3: Implement split pricing in PricingService

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

### Step 3.1: Replace calculateSubtotal body

Replace the body of `calculateSubtotal` with:

```php
$normalPrice = $unit->offer_price ?? $unit->base_price;
$occasionPrice = null;

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

    $occasionPrice = $occasionPriceModel?->price;
}

if ($occasion && $occasionPrice !== null) {
    $split = $occasion->splitRange($checkIn, $checkOut);
    $normalNights = $split['normal'];
    $occasionNights = $split['occasion'];
} else {
    $dates = UnitAvailabilityService::dateRange($checkIn->format('Y-m-d'), $checkOut->format('Y-m-d'));
    $normalNights = count($dates);
    $occasionNights = 0;
}

$originalSubtotal = ($normalNights * $normalPrice) + ($occasionNights * $occasionPrice);
$subtotal = $originalSubtotal;
$discountAmount = 0.00;

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

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

if ($discountAmount > $originalSubtotal) {
    $discountAmount = $originalSubtotal;
}

$subtotal = $originalSubtotal - $discountAmount;

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

Note: the original variable `$nights` bug is fixed by explicitly counting via `UnitAvailabilityService::dateRange`.

### Step 3.2: Run pricing tests

```bash
vendor/bin/pest tests/Unit/Services/PricingServiceTest.php
```

Expected: PASS.

---

## Task 4: Keep ReservationService invoice/receipt line items consistent

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

### Step 4.1: Update createDocument

Replace the first few lines of `createDocument`:

```php
public function createDocument(Reservation $reservation): array
{
    $unit = Unit::findOrFail($reservation->unit_id);
    $nights = count($this->dateRange(
        $reservation->check_in_date->format('Y-m-d'),
        $reservation->check_out_date->format('Y-m-d')
    ));

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

    $total = $pricing['subtotal'] + $pricing['discount_amount'];
    $price = $nights > 0 ? $total / $nights : 0;
    $discount = $pricing['discount_amount'];
    $netTotal = $pricing['subtotal'];
```

Keep the rest of the method (invoice/receipt creation) using `$price`, `$quantity`, `$total`, `$discount`, `$netTotal`.

### Step 4.2: Update recalculateReservation

Replace the price computation block at the top of `recalculateReservation`:

```php
$unit = Unit::findOrFail($reservation->unit_id);
$nights = count($this->dateRange(
    $reservation->check_in_date->format('Y-m-d'),
    $reservation->check_out_date->format('Y-m-d')
));

$occasion = $reservation->occasion_id ? Occasion::find($reservation->occasion_id) : null;
$pricing = PricingService::calculateSubtotal(
    $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'],
]);

$total = $pricing['subtotal'] + $pricing['discount_amount'];
$price = $nights > 0 ? $total / $nights : 0;
$discount = $reservation->discount_amount ?? 0.00;
$netTotal = $pricing['subtotal'];
```

Then update the invoice assignment block to use these new variables:

```php
$ownerInvoice->price = $price;
$ownerInvoice->quantity = $nights;
$ownerInvoice->total_price = $total;
$ownerInvoice->discount = $discount;
$ownerInvoice->net_price = $netTotal;
```

### Step 4.3: Run full pricing + reservation test suites

```bash
vendor/bin/pest tests/Unit/Services/PricingServiceTest.php tests/Feature/ReservationFixesTest.php tests/Feature/Owner/OccasionPriceTest.php
```

Expected: PASS.

---

## Task 5: Update documentation

**Files:**
- Modify: `occasions_prices_discounts.md`

### Step 5.1: Update Section 3.1 and 7.2

In Section 3.1 (`occasions` table), add:

```markdown
| `start_date` | nullable date | Fixed window start for the occasion. |
| `end_date` | nullable date | Fixed window end for the occasion. |
| `days_of_week` | nullable json | Recurring days (0=Sunday … 6=Saturday). e.g. `[5,6]` for weekend. |
```

In Section 7.2, replace Step 2 and Step 3 with:

```markdown
**Step 2 — Split nights into normal and occasion buckets**

```php
$normalPrice = $unit->offer_price ?? $unit->base_price;
$occasionPrice = null;

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

    $occasionPrice = $occasionPriceModel?->price;
}

if ($occasion && $occasionPrice !== null) {
    $split = $occasion->splitRange($checkIn, $checkOut);
    $normalNights = $split['normal'];
    $occasionNights = $split['occasion'];
} else {
    $normalNights = $nights;
    $occasionNights = 0;
}
```

**Step 3 — Compute pre-template subtotal**

```php
$originalSubtotal = ($normalNights * $normalPrice) + ($occasionNights * $occasionPrice);
```
```

### Step 5.2: Update use cases

Add a new use case after Use Case 3:

```markdown
### Use Case 3b — Reservation overlaps normal and occasion dates

1. Unit `Sea View 101` base price is **$100/night**.
2. Owner creates occasion `National Day` for `2026-12-02` → `2026-12-03`.
3. Owner sets occasion price to **$400/night**.
4. Customer books `2026-12-01` → `2026-12-04`.
5. `PricingService` splits the 3 nights:
   - 1 normal night × $100 = $100
   - 2 occasion nights × $400 = $800
6. Pre-template subtotal = $900.
```

---

## Task 6: Final verification

```bash
vendor/bin/pest
```

Expected: all tests pass.

---

## Self-Review

**Spec coverage:**
- Occasions define their own days via `start_date`/`end_date` and/or `days_of_week`: Task 1.
- Pricing splits normal vs. occasion nights: Task 2 + 3.
- Normal and occasion nights are summed and discounts apply to total: Task 3.
- Reservation documents reflect the split total: Task 4.
- Documentation updated: Task 5.

**Placeholder scan:** No TBD/TODO/fill-in-details found. Every step contains concrete code or commands.

**Type consistency:** `Occasion::splitRange()` returns `array{normal: int, occasion: int}`. `PricingService` uses those keys consistently. `createDocument` and `recalculateReservation` use `$pricing['subtotal']`, `$pricing['discount_amount']`, `$pricing['occasion_price']` consistently.
