# Wallet Payment Method & Refund-to-Wallet 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 `wallet` as a transaction payment method, deduct wallet for wallet payments, and credit the customer's wallet for all refunds while leaving invoice balances untouched for real customers.

**Architecture:** Plain string branching on `payment_method` and reservation `customer_type`. `PendingCustomer` keeps the old invoice-mutation refund behavior. `Customer` gets wallet credit/debit. The transaction table migration is edited directly (create table) to include `wallet` in the enum.

**Tech Stack:** Laravel 11, PHP 8.2+, Pest PHP, MySQL/SQLite, Sanctum tokens.

---

## Task 1: Update transactions migration

**Files:**
- Modify: `database/migrations/2026_06_08_175012_create_transactions_table.php`

- [ ] **Step 1: Change payment_method column to enum with wallet**

```php
$table->enum('payment_method', ['cash', 'bank', 'wallet']);
```

Replace the existing line:
```php
$table->string('payment_method');
```

- [ ] **Step 2: Refresh migrations in local environment**

Run:
```bash
php artisan migrate:fresh --seed
```

Expected: migrations complete without errors.

- [ ] **Step 3: Commit**

```bash
git add database/migrations/2026_06_08_175012_create_transactions_table.php
git commit -m "chore: add wallet to transactions payment_method enum"
```

---

## Task 2: Allow wallet in transaction request validation

**Files:**
- Modify: `app/Http/Requests/TransactionRequest.php`

- [ ] **Step 1: Update payment_method rule**

```php
'payment_method' => 'required|string|in:cash,bank,wallet',
```

Replace:
```php
'payment_method' => 'required|string|in:cash,bank',
```

- [ ] **Step 2: Commit**

```bash
git add app/Http/Requests/TransactionRequest.php
git commit -m "feat: allow wallet payment method in transaction request"
```

---

## Task 3: Update TransactionController refund and payment logic

**Files:**
- Modify: `app/Http/Controllers/TransactionController.php`

Current `store()` validation block (lines 68-77):
```php
$isRefund = $data['type'] === 'refund';

if ($isRefund) {
    if ($data['amount'] > $invoice->paid_amount) {
        return response()->json(['message' => 'Refund amount exceeds the paid balance.'], 422);
    }
}
elseif ($data['amount'] > $invoice->remaining_amount) {
    return response()->json(['message' => 'Payment amount exceeds the remaining balance.'], 422);
}
```

Current DB transaction block (lines 79-111):
```php
$transaction = DB::transaction(function () use ($data, $reservation, $invoice, $receipt, $isRefund) {
    unset($data['reservation_id']);

    $delta = $isRefund ? -$data['amount'] : $data['amount'];

    $transaction = Transaction::create(array_merge($data, [
        'invoice_id' => $invoice->id,
        'transaction_type' => $isRefund ? 'payout' : 'payment',
    ]));

    $paid = $invoice->paid_amount + $delta;

    $invoice->paid_amount = max(0, $paid);
    $invoice->remaining_amount = max(0, $invoice->net_price - $invoice->paid_amount);
    $invoice->save();

    if ($receipt) {
        $receipt->paid_amount = $invoice->paid_amount;
        $receipt->remaining_amount = $invoice->remaining_amount;
        $receipt->save();
    }

    if ($invoice->paid_amount >= $invoice->net_price) {
        $reservation->payment_status = 'paid';
    } elseif ($invoice->paid_amount > 0) {
        $reservation->payment_status = 'partially_paid';
    } else {
        $reservation->payment_status = 'unpaid';
    }
    $reservation->save();

    return $transaction;
});
```

- [ ] **Step 1: Replace the validation block**

```php
$isRefund = $data['type'] === 'refund';
$isWalletPayment = !$isRefund && $data['payment_method'] === 'wallet';
$isRealCustomer = $reservation->customer_type === Customer::class;

if ($isRefund) {
    $totalPaid = Transaction::where('invoice_id', $invoice->id)
        ->where('type', 'payment')
        ->sum('amount') ?? 0;

    $totalRefunded = Transaction::where('invoice_id', $invoice->id)
        ->where('type', 'refund')
        ->sum('amount') ?? 0;

    $availableRefund = $totalPaid - $totalRefunded;

    if ($data['amount'] > $availableRefund) {
        return response()->json(['message' => 'Refund amount exceeds the paid balance.'], 422);
    }
} elseif ($isWalletPayment) {
    if (!$isRealCustomer) {
        return response()->json(['message' => 'Wallet payments are not available for pending customers.'], 422);
    }

    if ($data['amount'] > $invoice->remaining_amount) {
        return response()->json(['message' => 'Payment amount exceeds the remaining balance.'], 422);
    }

    if ($data['amount'] > $reservation->customer->wallet) {
        return response()->json(['message' => 'Insufficient wallet balance.'], 422);
    }
} elseif ($data['amount'] > $invoice->remaining_amount) {
    return response()->json(['message' => 'Payment amount exceeds the remaining balance.'], 422);
}
```

- [ ] **Step 2: Replace the DB transaction block**

```php
$transaction = DB::transaction(function () use ($data, $reservation, $invoice, $receipt, $isRefund, $isWalletPayment, $isRealCustomer) {
    unset($data['reservation_id']);

    if ($isRefund && $isRealCustomer) {
        $data['payment_method'] = 'wallet';
    }

    $transaction = Transaction::create(array_merge($data, [
        'invoice_id' => $invoice->id,
        'transaction_type' => $isRefund ? 'payout' : 'payment',
    ]));

    if ($isRefund && $isRealCustomer) {
        $customer = $reservation->customer;
        $customer->wallet += $data['amount'];
        $customer->save();
    } else {
        $delta = $isRefund ? -$data['amount'] : $data['amount'];
        $paid = $invoice->paid_amount + $delta;

        $invoice->paid_amount = max(0, $paid);
        $invoice->remaining_amount = max(0, $invoice->net_price - $invoice->paid_amount);
        $invoice->save();

        if ($receipt) {
            $receipt->paid_amount = $invoice->paid_amount;
            $receipt->remaining_amount = $invoice->remaining_amount;
            $receipt->save();
        }

        if ($invoice->paid_amount >= $invoice->net_price) {
            $reservation->payment_status = 'paid';
        } elseif ($invoice->paid_amount > 0) {
            $reservation->payment_status = 'partially_paid';
        } else {
            $reservation->payment_status = 'unpaid';
        }
        $reservation->save();

        if ($isWalletPayment) {
            $customer = $reservation->customer;
            $customer->wallet -= $data['amount'];
            $customer->save();
        }
    }

    return $transaction;
});
```

- [ ] **Step 3: Run existing tests**

```bash
php artisan test
```

Expected: existing tests still pass.

- [ ] **Step 4: Commit**

```bash
git add app/Http/Controllers/TransactionController.php
git commit -m "feat: wallet payments and refund-to-wallet in transaction controller"
```

---

## Task 4: Update ReservationService auto-refund for wallet

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

Target block lines 137-163:
```php
// When shortened dates cause an overpayment, refund the difference to the customer's wallet.
if ($datesShortened && $ownerInvoice && $ownerInvoice->paid_amount > $ownerInvoice->net_price) {
    $refundAmount = $ownerInvoice->paid_amount - $ownerInvoice->net_price;

    Transaction::create([
        'invoice_id' => $ownerInvoice->id,
        'transaction_type' => 'payout',
        'amount' => $refundAmount,
        'type' => 'refund',
        'payment_method' => 'cash',
        'reason' => 'Refund for shortened reservation dates',
    ]);

    $ownerInvoice->paid_amount = $netTotal;
    $ownerInvoice->remaining_amount = 0;
    $ownerInvoice->save();

    if ($receipt) {
        $this->mirrorInvoiceToReceipt($ownerInvoice, $receipt);
    }

    $customer = $reservation->customer;
    if ($customer instanceof Customer) {
        $customer->wallet += $refundAmount;
        $customer->save();
    }
}
```

- [ ] **Step 1: Replace the block with transaction-total-aware logic**

Use transaction totals for the effective paid amount so manual real-customer refunds (which do not reduce `invoice.paid_amount`) are not double-counted.

```php
// When shortened dates cause an overpayment, refund the difference to the customer's wallet.
// Use transaction totals because real-customer manual refunds credit the wallet
// without changing invoice.paid_amount.
$totalPaid = Transaction::where('invoice_id', $ownerInvoice->id)
    ->where('type', 'payment')
    ->sum('amount') ?? 0;

$totalRefunded = Transaction::where('invoice_id', $ownerInvoice->id)
    ->where('type', 'refund')
    ->sum('amount') ?? 0;

$effectivePaid = $totalPaid - $totalRefunded;

if ($datesShortened && $ownerInvoice && $effectivePaid > $ownerInvoice->net_price) {
    $refundAmount = $effectivePaid - $ownerInvoice->net_price;
    $customer = $reservation->customer;
    $isRealCustomer = $customer instanceof Customer;

    Transaction::create([
        'invoice_id' => $ownerInvoice->id,
        'transaction_type' => 'payout',
        'amount' => $refundAmount,
        'type' => 'refund',
        'payment_method' => $isRealCustomer ? 'wallet' : 'cash',
        'reason' => 'Refund for shortened reservation dates',
    ]);

    $ownerInvoice->paid_amount = $netTotal;
    $ownerInvoice->remaining_amount = 0;
    $ownerInvoice->save();

    if ($receipt) {
        $this->mirrorInvoiceToReceipt($ownerInvoice, $receipt);
    }

    if ($isRealCustomer) {
        $customer->wallet += $refundAmount;
        $customer->save();
    }
}
```

- [ ] **Step 2: Run existing tests**

```bash
php artisan test
```

Expected: existing tests still pass.

- [ ] **Step 3: Commit**

```bash
git add app/Services/ReservationService.php
git commit -m "feat: credit wallet on date-shorten auto-refund for real customers"
```

---

## Task 5: Add feature tests for wallet payments and refunds

**Files:**
- Create: `tests/Feature/WalletPaymentRefundTest.php`

- [ ] **Step 1: Create the test file**

```php
<?php

use App\Models\Building;
use App\Models\City;
use App\Models\Country;
use App\Models\Currency;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\PendingCustomer;
use App\Models\Region;
use App\Models\Reservation;
use App\Models\Transaction;
use App\Models\Unit;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

beforeEach(function () {
    $this->seed(RolesAndPermissionsSeeder::class);

    Storage::fake('media');

    $currency = Currency::create(['code' => 'USD', 'name' => 'US Dollar', 'symbol' => '$']);
    $country = Country::create(['name' => 'Test Country', 'currency_id' => $currency->id]);
    $city = City::create(['name' => 'Test City', 'country_id' => $country->id]);
    $region = Region::create(['name' => 'Test Region', 'city_id' => $city->id]);

    $this->ownerUser = User::factory()->create([
        'name' => 'Owner One',
        'email' => 'owner@example.com',
        'password' => bcrypt('Password123!'),
    ]);
    $this->ownerUser->assignRole('owner');
    $this->ownerUser->owner()->create(['status' => 'active', 'whatsapp_number' => '1234567890']);

    $this->customerUser = User::factory()->create([
        'name' => 'Customer One',
        'email' => 'customer@example.com',
        'password' => bcrypt('Password123!'),
    ]);
    $this->customerUser->assignRole('customer');
    $this->customerUser->customer()->create(['whatsapp_number' => '5555555555']);

    $this->building = Building::create([
        'name' => 'Test Building',
        'owner_id' => $this->ownerUser->owner->id,
        'region_id' => $region->id,
        'currency_id' => $currency->id,
        'check_in_time' => '14:00',
        'check_out_time' => '12:00',
        'status' => 'active',
        'slug' => 'test-building',
    ]);

    $this->unit = Unit::create([
        'building_id' => $this->building->id,
        'floor' => 1,
        'name_or_number' => '101',
        'rooms' => 2,
        'base_price' => 100,
        'offer_price' => 90,
        'payment_method' => 'cash',
        'guest_type' => 'both',
        'max_adults' => 2,
        'max_children' => 1,
        'max_child_age' => 12,
        'status' => 'available',
        'slug' => 'unit-101',
    ]);

    $this->ownerToken = $this->ownerUser->createToken('test')->plainTextToken;
    $this->customerToken = $this->customerUser->createToken('test')->plainTextToken;
});

function createCustomerReservation($test): Reservation
{
    $photo = UploadedFile::fake()->image('id-card.jpg');

    $response = $test->withToken($test->customerToken)->postJson('/api/reservations', [
        'unit_id' => $test->unit->id,
        'check_in_date' => '2026-08-01',
        'check_out_date' => '2026-08-04',
        'adults_count' => 2,
        'children_count' => 1,
        'photo' => $photo,
    ]);

    $response->assertSuccessful();

    return Reservation::findOrFail($response->json('data.id'));
}

function createPendingReservation($test): Reservation
{
    $photo = UploadedFile::fake()->image('id-card.jpg');

    $response = $test->withToken($test->ownerToken)->postJson('/api/reservations/on-arrival', [
        'unit_id' => $test->unit->id,
        'check_in_date' => '2026-08-01',
        'check_out_date' => '2026-08-04',
        'adults_count' => 2,
        'children_count' => 1,
        'photo' => $photo,
        'name' => 'Pending Customer',
        'phone' => '5555555555',
        'whatsapp_number' => '5555555555',
    ]);

    $response->assertSuccessful();

    return Reservation::findOrFail($response->json('data.id'));
}

function postTransaction($test, Reservation $reservation, array $payload): \Illuminate\Testing\TestResponse
{
    return $test->withToken($test->ownerToken)
        ->postJson('/api/transactions', array_merge([
            'reservation_id' => $reservation->id,
        ], $payload));
}

it('deducts wallet on wallet payment for real customer', function () {
    $reservation = createCustomerReservation($this);
    $customer = $reservation->customer;
    $customer->wallet = 500;
    $customer->save();

    $invoice = $reservation->invoices()->first();
    $originalRemaining = $invoice->remaining_amount;

    $response = postTransaction($this, $reservation, [
        'amount' => 200,
        'type' => 'payment',
        'payment_method' => 'wallet',
        'reason' => 'Wallet payment',
    ]);

    $response->assertSuccessful();

    $customer->refresh();
    $invoice->refresh();

    expect($customer->wallet)->toBe(300.0)
        ->and($invoice->paid_amount)->toBe(200.0)
        ->and($invoice->remaining_amount)->toBe($originalRemaining - 200);
});

it('rejects wallet payment for pending customer', function () {
    $reservation = createPendingReservation($this);

    postTransaction($this, $reservation, [
        'amount' => 100,
        'type' => 'payment',
        'payment_method' => 'wallet',
        'reason' => 'Wallet payment',
    ])->assertUnprocessable();
});

it('rejects wallet payment with insufficient balance', function () {
    $reservation = createCustomerReservation($this);
    $customer = $reservation->customer;
    $customer->wallet = 50;
    $customer->save();

    postTransaction($this, $reservation, [
        'amount' => 100,
        'type' => 'payment',
        'payment_method' => 'wallet',
        'reason' => 'Wallet payment',
    ])->assertUnprocessable();
});

it('credits wallet and leaves invoice untouched for real customer refund', function () {
    $reservation = createCustomerReservation($this);
    $customer = $reservation->customer;
    $customer->wallet = 0;
    $customer->save();

    $invoice = $reservation->invoices()->first();

    // Pay the invoice in full first
    postTransaction($this, $reservation, [
        'amount' => $invoice->remaining_amount,
        'type' => 'payment',
        'payment_method' => 'cash',
        'reason' => 'Full cash payment',
    ])->assertSuccessful();

    $invoice->refresh();
    $paidBeforeRefund = $invoice->paid_amount;
    $remainingBeforeRefund = $invoice->remaining_amount;

    $response = postTransaction($this, $reservation, [
        'amount' => 100,
        'type' => 'refund',
        'payment_method' => 'cash',
        'reason' => 'Partial refund',
    ]);

    $response->assertSuccessful();

    $customer->refresh();
    $invoice->refresh();

    expect($customer->wallet)->toBe(100.0)
        ->and($invoice->paid_amount)->toBe($paidBeforeRefund)
        ->and($invoice->remaining_amount)->toBe($remainingBeforeRefund);
});

it('mutates invoice for pending customer refund', function () {
    $reservation = createPendingReservation($this);
    $invoice = $reservation->invoices()->first();

    // Pay in full
    postTransaction($this, $reservation, [
        'amount' => $invoice->remaining_amount,
        'type' => 'payment',
        'payment_method' => 'cash',
        'reason' => 'Full cash payment',
    ])->assertSuccessful();

    $invoice->refresh();

    postTransaction($this, $reservation, [
        'amount' => 100,
        'type' => 'refund',
        'payment_method' => 'cash',
        'reason' => 'Partial refund',
    ])->assertSuccessful();

    $invoice->refresh();

    expect($invoice->paid_amount)->toBeLessThan($invoice->net_price)
        ->and($invoice->remaining_amount)->toBe(100.0);
});

it('rejects refund exceeding paid balance for real customer', function () {
    $reservation = createCustomerReservation($this);
    $customer = $reservation->customer;
    $invoice = $reservation->invoices()->first();

    postTransaction($this, $reservation, [
        'amount' => 100,
        'type' => 'payment',
        'payment_method' => 'cash',
        'reason' => 'Partial payment',
    ])->assertSuccessful();

    postTransaction($this, $reservation, [
        'amount' => $invoice->net_price,
        'type' => 'refund',
        'payment_method' => 'cash',
        'reason' => 'Over refund',
    ])->assertUnprocessable();
});
```

- [ ] **Step 2: Run the new tests**

```bash
php artisan test tests/Feature/WalletPaymentRefundTest.php
```

Expected: all tests pass.

- [ ] **Step 3: Commit**

```bash
git add tests/Feature/WalletPaymentRefundTest.php
git commit -m "test: add wallet payment and refund feature tests"
```

---

## Task 6: Full test suite verification

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

```bash
php artisan test
```

Expected: all tests pass.

- [ ] **Step 2: Commit any remaining changes**

If all tests pass and there are no uncommitted changes, no action needed.

---

## Self-Review Checklist

1. **Spec coverage:**
   - Wallet added to transaction payment_method column ✅ Task 1
   - Wallet allowed in request validation ✅ Task 2
   - Wallet payments deduct real Customer wallet ✅ Task 3
   - Cash/bank payments unchanged ✅ Task 3
   - Refunds for real Customer credit wallet without mutating invoice ✅ Task 3
   - Refunds for PendingCustomer keep old behavior ✅ Task 3
   - Auto-refund on date-shorten uses wallet for real Customer ✅ Task 4
   - Auto-refund on date-shorten keeps old behavior for PendingCustomer ✅ Task 4

2. **Placeholder scan:** No TBD/TODO placeholders. Each step includes exact code or commands.

3. **Type consistency:** `Customer` model, `customer_type === Customer::class`, `$reservation->customer`, `wallet` decimal cast all consistent with explored codebase.
