# Manual On-Arrival Reservation + WhatsApp Notification 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:** Change the owner/employee on-arrival reservation flow so OTP is only required for new pending customers, registered/existing customers get immediate confirmation, and a WhatsApp notification is sent after every manual on-arrival reservation is confirmed.

**Architecture:** Add `phone_verified_at` to `pending_customers` to track whether a pending customer's phone has been OTP-verified. `validateOnArrival` branches on this flag: verified customers get an immediate confirmed reservation; unverified pending customers receive an OTP and a pending hold. `verifyOtp` confirms the held reservation, marks `phone_verified_at`, and triggers a new `ReservationCreated` notification sent via the existing WhatsApp channel.

**Tech Stack:** Laravel 12, PHP 8.4, Pest, Spatie Media Library, CoreVerde WhatsApp service, Sanctum.

---

## File structure

| File | Responsibility |
|------|----------------|
| `database/migrations/2026_06_29_000000_add_phone_verified_at_to_pending_customers.php` | Adds `phone_verified_at` nullable timestamp to `pending_customers`. |
| `app/Http/Controllers/ReservationController.php` | `prepareOnArrival` stops sending OTP; `validateOnArrival` branches between immediate confirmation and OTP send. |
| `app/Http/Controllers/Api/PendingCustomerController.php` | `verifyOtp` sets `phone_verified_at` and confirms the pending reservation. |
| `app/Services/ReservationService.php` | `persistFromPendingReservation` removes the `is_verified` update and dispatches `ReservationCreated`. |
| `app/Notifications/ReservationCreated.php` | New notification rendered to WhatsApp and database channels. |
| `tests/Feature/PendingCustomerFlowTest.php` | Updated tests covering registered, existing pending, and new pending flows plus WhatsApp dispatch. |

---

### Task 1: Add `phone_verified_at` to pending customers

**Files:**
- Create: `database/migrations/2026_06_29_000000_add_phone_verified_at_to_pending_customers.php`

- [ ] **Step 1: Create the 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('pending_customers', function (Blueprint $table) {
            $table->timestamp('phone_verified_at')->nullable()->after('is_verified');
        });
    }

    public function down(): void
    {
        Schema::table('pending_customers', function (Blueprint $table) {
            $table->dropColumn('phone_verified_at');
        });
    }
};
```

- [ ] **Step 2: Run the migration**

Run: `php artisan migrate`

Expected: migration succeeds with no errors.

- [ ] **Step 3: Commit**

```bash
git add database/migrations/2026_06_29_000000_add_phone_verified_at_to_pending_customers.php
git commit -m "feat: add phone_verified_at to pending customers"
```

---

### Task 2: Create the ReservationCreated notification

**Files:**
- Create: `app/Notifications/ReservationCreated.php`

- [ ] **Step 1: Write the notification class**

```php
<?php

namespace App\Notifications;

use App\Models\Reservation;
use App\Notifications\Channels\AppDatabaseChannel;
use App\Notifications\Channels\WhatsAppChannel;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;

class ReservationCreated extends Notification
{
    use Queueable;

    public function __construct(public Reservation $reservation) {}

    public function via(object $notifiable): array
    {
        return [AppDatabaseChannel::class, WhatsAppChannel::class];
    }

    public function toWhatsApp(object $notifiable): array
    {
        $reservation = $this->reservation;
        $unit = $reservation->unit;
        $building = $unit?->building;

        $message = sprintf(
            "Hi %s,\nA reservation has been made for you.\nReservation: %s\nBuilding: %s\nUnit: %s\nCheck-in: %s\nCheck-out: %s\nTotal: %.2f",
            $notifiable->name ?? 'Guest',
            $reservation->reservation_number,
            $building?->name ?? 'N/A',
            $unit?->name_or_number ?? 'N/A',
            $reservation->check_in_date->format('Y-m-d'),
            $reservation->check_out_date->format('Y-m-d'),
            $reservation->total_price
        );

        return [
            'to' => $notifiable->whatsapp_number ?? $notifiable->phone ?? '',
            'message' => $message,
        ];
    }

    public function toDatabase(object $notifiable): array
    {
        return [
            'title' => 'Reservation Created',
            'body' => "Your reservation {$this->reservation->reservation_number} has been confirmed.",
            'type' => self::class,
        ];
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add app/Notifications/ReservationCreated.php
git commit -m "feat: add ReservationCreated notification for WhatsApp"
```

---

### Task 3: Update ReservationService to dispatch notification and stop setting is_verified

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

- [ ] **Step 1: Add the import**

At the top of `app/Services/ReservationService.php`, add:

```php
use App\Notifications\ReservationCreated;
use Illuminate\Support\Facades\Notification;
```

- [ ] **Step 2: Remove is_verified update and add notification dispatch**

Replace the end of `persistFromPendingReservation` (around lines 518-527):

```php
            $pendingReservation->update([
                'status' => 'confirmed',
                'otp_verified_at' => now(),
            ]);

            // Mark the pending customer as verified if applicable.
            $customer = $pendingReservation->customer;
            if ($customer instanceof PendingCustomer) {
                $customer->update(['is_verified' => true]);
            }

            return $reservation;
```

with:

```php
            $pendingReservation->update([
                'status' => 'confirmed',
                'otp_verified_at' => now(),
            ]);

            $customer = $pendingReservation->customer;
            Notification::send($customer, new ReservationCreated($reservation));

            return $reservation;
```

- [ ] **Step 3: Verify the method looks correct**

The full `persistFromPendingReservation` should now:
1. Create the `Reservation` with `source = 'manual'`.
2. Apply promo code if present.
3. Reassign held dates to the real reservation.
4. Create invoice/receipt documents.
5. Schedule reminders.
6. Move ID photo if present.
7. Mark pending reservation as confirmed.
8. Dispatch `ReservationCreated` notification to the customer.

- [ ] **Step 4: Run existing reservation tests**

Run: `php artisan test tests/Feature/PendingCustomerFlowTest.php`

Expected: Some tests may fail because behavior changed; that is acceptable at this stage. The full test updates happen in Task 6.

- [ ] **Step 5: Commit**

```bash
git add app/Services/ReservationService.php
git commit -m "feat: dispatch ReservationCreated notification and stop setting is_verified on confirm"
```

---

### Task 4: Confirm prepareOnArrival requires no change

**Files:**
- Read only: `app/Http/Controllers/ReservationController.php:149-205`

The current `prepareOnArrival` implementation already does not send OTP. It only resolves or creates the customer. No code changes are needed here.

- [ ] **Step 1: Verify prepareOnArrival has no OTP call**

Read `app/Http/Controllers/ReservationController.php` lines 149-205 and confirm it does not call `OtpService::send`.

Expected: no `OtpService::send` call inside `prepareOnArrival`.

---

### Task 5: Update validateOnArrival to branch on phone_verified_at

**Files:**
- Modify: `app/Http/Controllers/ReservationController.php:211-290`

- [ ] **Step 1: Replace validateOnArrival**

Replace the current `validateOnArrival` method with:

```php
    /**
     * @throws Throwable
     * @throws RandomException
     */
    public function validateOnArrival(OnArrivalValidateRequest $request)
    {
        $data = $request->validated();
        $phone = $data['phone'];

        $customer = $this->resolveCustomer($phone);

        if (! $customer) {
            return response()->json(['message' => 'No customer found for this phone number. Prepare the customer first.'], 422);
        }

        $unit = Unit::findOrFail($data['unit_id']);

        $promoCode = null;
        if (! empty($data['promo_code'])) {
            $promoCode = PromoCodeService::findValidForUnit($data['promo_code'], $unit);

            if (! $promoCode) {
                return response()->json(['message' => 'The promo code is invalid, expired, or already used.'], 422);
            }
        }

        $totalPrice = ReservationService::calculateTotal(
            $unit->id,
            $data['check_in_date'],
            $data['check_out_date'],
            $promoCode
        );

        return DB::transaction(function () use ($request, $data, $phone, $customer, $unit, $promoCode, $totalPrice) {
            // Cancel any previous pending hold for this customer.
            $this->cancelActivePendingReservationsFor($customer);

            $pendingReservation = PendingReservation::create([
                'customer_type' => $customer::class,
                'customer_id' => $customer->id,
                'unit_id' => $unit->id,
                'check_in_date' => $data['check_in_date'],
                'check_out_date' => $data['check_out_date'],
                'adults_count' => $data['adults_count'],
                'children_count' => $data['children_count'],
                'promo_code_id' => $promoCode?->id,
                'total_price' => $totalPrice,
                'notes' => $data['notes'] ?? null,
                'status' => 'pending',
                'expires_at' => Carbon::now()->addMinutes(10),
            ]);

            try {
                UnitAvailabilityService::holdDates(
                    $unit->id,
                    $data['check_in_date'],
                    $data['check_out_date'],
                    $pendingReservation->id
                );
            } catch (ReservationUnavailableException $e) {
                $pendingReservation->update(['status' => 'cancelled']);

                return response()->json(['message' => 'Selected dates are not available.'], 422);
            }

            if ($request->hasFile('photo')) {
                $pendingReservation->addMedia($request->file('photo'))
                    ->toMediaCollection('documents');
            }

            // New unverified pending customers still require OTP to confirm the reservation.
            if ($customer instanceof PendingCustomer && is_null($customer->phone_verified_at)) {
                $otp = OtpService::send($phone);

                $response = [
                    'pending_reservation' => PendingReservationResource::make($pendingReservation->load(['customer', 'unit', 'promoCode'])),
                    'message' => 'Reservation details validated and dates held. Verify the OTP to confirm.',
                ];

                if (app()->environment('local', 'testing')) {
                    $response['otp'] = $otp;
                }

                return response()->json($response, 201);
            }

            // Registered customers and phone-verified pending customers are confirmed immediately.
            return ReservationService::confirmPendingReservation($pendingReservation);
        });
    }
```

- [ ] **Step 2: Run the reservation flow tests**

Run: `php artisan test tests/Feature/PendingCustomerFlowTest.php`

Expected: failures because tests still expect OTP for registered customers.

- [ ] **Step 3: Commit**

```bash
git add app/Http/Controllers/ReservationController.php
git commit -m "feat: confirm verified customers immediately, send OTP only for unverified pending"
```

---

### Task 6: Update verifyOtp to set phone_verified_at

**Files:**
- Modify: `app/Http/Controllers/Api/PendingCustomerController.php:25-58`

- [ ] **Step 1: Update verifyOtp**

Replace the current `verifyOtp` method with:

```php
    /**
     * Step 3 of the on-arrival/phone reservation flow.
     *
     * Verifies the OTP for a new pending customer and converts the held pending reservation into a real one.
     *
     * @throws Throwable
     */
    public function verifyOtp(OnArrivalReservationRequest $request)
    {
        $phone = $request->validated('phone');
        $otp = $request->validated('otp');

        if (! OtpService::verify($phone, $otp)) {
            return response()->json(['message' => 'Invalid or expired OTP'], 422);
        }

        $customer = $this->resolveCustomer($phone);

        if (! $customer) {
            return response()->json(['message' => 'No customer found for this phone number.'], 422);
        }

        if ($customer instanceof PendingCustomer) {
            $customer->update(['phone_verified_at' => now()]);
        }

        $pendingReservation = PendingReservation::where('customer_type', $customer::class)
            ->where('customer_id', $customer->id)
            ->where('status', 'pending')
            ->where('expires_at', '>', now())
            ->latest()
            ->first();

        if (! $pendingReservation) {
            return response()->json(['message' => 'No pending reservation found. Please validate the reservation first.'], 422);
        }

        try {
            return ReservationService::confirmPendingReservation($pendingReservation);
        } catch (ReservationUnavailableException $e) {
            report($e);

            return response()->json(['message' => 'The reservation could not be confirmed.'], 422);
        }
    }
```

- [ ] **Step 2: Run the reservation flow tests**

Run: `php artisan test tests/Feature/PendingCustomerFlowTest.php`

Expected: still some failures until tests are updated in Task 7.

- [ ] **Step 3: Commit**

```bash
git add app/Http/Controllers/Api/PendingCustomerController.php
git commit -m "feat: set phone_verified_at when OTP is verified"
```

---

### Task 7: Update PendingCustomerFlowTest

**Files:**
- Modify: `tests/Feature/PendingCustomerFlowTest.php`

- [ ] **Step 1: Update helper functions and existing tests**

Replace the helper functions at the top:

```php
function prepareCustomer($test, string $phone, array $overrides = []): string
{
    $response = $test->withToken($test->ownerToken)->postJson('/api/v1/reservations/on-arrival/prepare', array_merge([
        'name' => 'Pending Customer',
        'phone' => $phone,
        'whatsapp_number' => $phone,
    ], $overrides));

    $response->assertCreated();

    return $response->json('customer') ? 'customer' : 'pending_customer';
}

function validateReservation($test, string $phone, array $overrides = []): array
{
    $response = $test->withToken($test->ownerToken)->postJson('/api/v1/reservations/on-arrival/validate', array_merge([
        'phone' => $phone,
        'unit_id' => $test->unit->id,
        'check_in_date' => '2026-08-01',
        'check_out_date' => '2026-08-04',
        'adults_count' => 2,
        'children_count' => 1,
    ], $overrides));

    $response->assertCreated();

    return [
        'pending_reservation_id' => $response->json('pending_reservation.id'),
        'otp' => $response->json('otp'),
        'reservation_id' => $response->json('data.id'),
    ];
}

function createVerifiedPendingCustomer($test, string $phone): PendingCustomer
{
    prepareCustomer($test, $phone);
    $validated = validateReservation($test, $phone);

    $test->withToken($test->ownerToken)->postJson('/api/v1/reservations/on-arrival/verify', [
        'phone' => $phone,
        'otp' => $validated['otp'],
    ])->assertCreated();

    return PendingCustomer::byPhone($phone)->whereNotNull('phone_verified_at')->first();
}
```

- [ ] **Step 2: Update the registered customer test**

Replace the test "prepares an on-arrival reservation for a registered customer and sends an otp" with:

```php
it('prepares an on-arrival reservation for a registered customer without otp', function () {
    $phone = '+12025550124';

    $response = $this->withToken($this->ownerToken)->postJson('/api/v1/reservations/on-arrival/prepare', [
        'name' => 'Registered Customer',
        'phone' => $phone,
        'whatsapp_number' => $phone,
    ]);

    $response->assertCreated()
        ->assertJsonPath('customer.whatsapp_number', $phone)
        ->assertJsonMissing(['otp', 'pending_customer']);

    expect(PendingCustomer::byPhone($phone)->count())->toBe(0);
});
```

- [ ] **Step 3: Update the registered customer reservation test**

Replace the test "creates a reservation for a registered customer when a valid otp is supplied" with:

```php
it('creates a reservation for a registered customer without otp', function () {
    $photo = UploadedFile::fake()->image('id-card.jpg');
    $phone = '+12025550124';

    prepareCustomer($this, $phone);
    $response = $this->withToken($this->ownerToken)->postJson('/api/v1/reservations/on-arrival/validate', [
        'phone' => $phone,
        'unit_id' => $this->unit->id,
        'check_in_date' => '2026-08-01',
        'check_out_date' => '2026-08-04',
        'adults_count' => 2,
        'children_count' => 1,
        'photo' => $photo,
    ]);

    $response->assertSuccessful();

    $reservation = Reservation::findOrFail($response->json('data.id'));
    expect($reservation->customer_type)->toBe(Customer::class)
        ->and($reservation->customer->user->phone)->toBe($phone)
        ->and($reservation->getFirstMedia('documents'))->not->toBeNull();
});
```

- [ ] **Step 4: Update the OTP verification test**

Replace the test "verifies a pending customer with a valid otp" with:

```php
it('verifies a new pending customer with a valid otp and sets phone_verified_at', function () {
    $phone = '+12025550123';

    prepareCustomer($this, $phone);
    $validated = validateReservation($this, $phone);

    $this->withToken($this->ownerToken)->postJson('/api/v1/reservations/on-arrival/verify', [
        'phone' => $phone,
        'otp' => $validated['otp'],
    ])->assertCreated();

    $pending = PendingCustomer::byPhone($phone)->first();
    expect($pending->phone_verified_at)->not->toBeNull();
});
```

- [ ] **Step 5: Add existing pending customer skip-OTP test**

Add a new test after the registered customer tests:

```php
it('skips otp for an existing phone-verified pending customer', function () {
    $phone = '+12025550197';
    $customer = PendingCustomer::create([
        'name' => 'Already Verified',
        'phone' => $phone,
        'whatsapp_number' => $phone,
        'phone_verified_at' => now(),
    ]);

    prepareCustomer($this, $phone);
    $response = $this->withToken($this->ownerToken)->postJson('/api/v1/reservations/on-arrival/validate', [
        'phone' => $phone,
        'unit_id' => $this->unit->id,
        'check_in_date' => '2026-08-01',
        'check_out_date' => '2026-08-04',
        'adults_count' => 2,
        'children_count' => 1,
    ]);

    $response->assertSuccessful()
        ->assertJsonPath('data.customer.phone', $phone)
        ->assertJsonMissing(['pending_reservation', 'otp']);

    expect($customer->fresh()->phone_verified_at)->not->toBeNull();
});
```

- [ ] **Step 6: Update remaining tests that expect OTP for registered customers**

Search `tests/Feature/PendingCustomerFlowTest.php` for any remaining assertions that expect `otp` for registered customers and remove or adjust them.

- [ ] **Step 7: Add WhatsApp dispatch test**

Add a new test:

```php
it('sends a whatsapp notification when a manual reservation is confirmed', function () {
    \Illuminate\Support\Facades\Notification::fake();

    $phone = '+12025550124';

    prepareCustomer($this, $phone);
    $response = $this->withToken($this->ownerToken)->postJson('/api/v1/reservations/on-arrival/validate', [
        'phone' => $phone,
        'unit_id' => $this->unit->id,
        'check_in_date' => '2026-08-01',
        'check_out_date' => '2026-08-04',
        'adults_count' => 2,
        'children_count' => 1,
    ]);

    $response->assertSuccessful();

    $reservation = Reservation::findOrFail($response->json('data.id'));
    \Illuminate\Support\Facades\Notification::assertSentTo(
        [$reservation->customer],
        \App\Notifications\ReservationCreated::class
    );
});
```

- [ ] **Step 8: Run the full test file**

Run: `php artisan test tests/Feature/PendingCustomerFlowTest.php`

Expected: PASS after all adjustments.

- [ ] **Step 9: Commit**

```bash
git add tests/Feature/PendingCustomerFlowTest.php
git commit -m "test: update on-arrival flow for otp-on-new-pending-only and whatsapp notification"
```

---

### Task 8: Full regression test

- [ ] **Step 1: Run the full test suite**

Run: `php artisan test`

Expected: PASS. If any other tests rely on the old `is_verified` behavior or OTP flow, update them in place.

- [ ] **Step 2: Fix any failures**

Address any failing tests caused by the changed behavior. Common issues:
- Tests expecting `pending_customer.is_verified` to become true after OTP.
- Tests expecting registered customers to receive OTP.

- [ ] **Step 3: Final commit**

```bash
git add -A
git commit -m "test: full regression fixes for manual on-arrival whatsapp flow"
```

---

## Plan self-review

### Spec coverage

| Spec requirement | Implementing task |
|------------------|-------------------|
| OTP only for new pending customers | Task 5 (`validateOnArrival` branch), Task 6 (`verifyOtp` sets `phone_verified_at`) |
| Reservation approval not gated by OTP for registered/existing pending | Task 5 (immediate `confirmPendingReservation`) |
| WhatsApp notification after manual reservation confirmed | Task 2 (notification), Task 3 (dispatch in service) |
| Keep `PendingReservation` infrastructure | Task 5 still creates pending hold before confirmation |
| `is_verified` not set by OTP | Task 3 removes the update |

### Placeholder scan

No TBD, TODO, or vague steps. Each step includes exact file paths and code.

### Type consistency

- `phone_verified_at` is used consistently as a nullable timestamp.
- `ReservationCreated` notification accepts `Reservation` and sends to `Customer|PendingCustomer`.
- `validateOnArrival` returns either a pending reservation JSON or a `ReservationResource` via `confirmPendingReservation`.

No inconsistencies found.
