# Layer 8 Plan 2: Admin Orders + Money Movement

> **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:** Build the admin order detail surface with three money-movement actions outside the dispute flow — manual refund (partial or full), force-cancel, and standalone Transfer reversal — plus the buyer/seller notifications that make those actions visible to the parties affected.

**Architecture:** (1) Backend — `AdminOrderActions` service owns the three actions, all gated by required justification, all wrapped in DB transactions, all writing `spatie/laravel-activitylog` entries; reuses existing `StripeService::reverseTransfer` (Plan 1) and `refundForOrder`. (2) Three POST endpoints under `/v1/admin/orders/{order}/...` plus a new `GET /v1/admin/orders/{order}` so the detail page has live data to render. (3) Notifications: `BuyerRefundIssuedNotification` (admin-issued refunds) and `SellerOrderForceCancelledNotification` (admin-initiated cancellations). (4) Frontend — `/admin/orders/[id]` detail page; reuses the existing seller order detail's panel layout but adds an admin-only action strip with three confirmation dialogs (the `ConfirmWithJustificationDialog` from Plan 1). (5) Each action invalidates the dashboard, the order list, and the specific order's query keys.

**Tech Stack:** Laravel 11, Pest PHP tests, Postgres, `spatie/laravel-activitylog`, `laravel/notifications` (mail + database channels), Stripe PHP SDK, OpenAPI → `openapi-typescript`, Next.js 15 App Router, TanStack Query, Tailwind, Vitest + React Testing Library.

**Spec:** `docs/superpowers/specs/2026-05-04-layer-8-admin-console-disputes-design.md` (section: "Order detail (admin view)" lines 167–186)
**Prerequisites:** Plan 1 merged. `EnsureAdmin` middleware, `StripeService::reverseTransfer`, `CancellationReason::AdminForced`, and the `ConfirmWithJustificationDialog` component all exist.
**Successor plans:** `2026-XX-XX-layer-8-admin-stores-suspension.md`, `2026-XX-XX-layer-8-admin-inbox-activity.md`.

---

## Phase A — Backend service + endpoints

### Task 1: `AdminOrderActions` service

**Files:**
- Create: `api/app/Modules/Admin/Services/AdminOrderActions.php`
- Test: `api/tests/Feature/Admin/AdminOrderActionsTest.php`

The service owns three methods — `refund`, `forceCancel`, `reverseTransfer` — each takes a `User $admin`, the `Order`, an amount/reason as appropriate, and a justification string. Each writes an activitylog entry. Stripe operations use deterministic idempotency keys.

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\User;
use App\Modules\Admin\Services\AdminOrderActions;
use App\Modules\Checkout\Services\StripeService;
use App\Support\Enums\CancellationReason;
use App\Support\Enums\OrderStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Spatie\Activitylog\Models\Activity;
use Stripe\Refund;
use Stripe\TransferReversal;
use Tests\TestCase;

class AdminOrderActionsTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_refund_calls_stripe_and_logs_activity(): void
    {
        $admin = User::factory()->create();
        $purchase = Purchase::factory()->create(['stripe_payment_intent_id' => 'pi_r']);
        $order = Order::factory()->create(['purchase_id' => $purchase->id, 'subtotal' => 5000, 'shipping_cost' => 500]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->once()->with('pi_r', 3000)
            ->andReturn(Refund::constructFrom(['id' => 're_r']));
        $this->app->instance(StripeService::class, $stripe);

        app(AdminOrderActions::class)->refund(
            order: $order,
            amountCents: 3000,
            justification: 'Goodwill refund — customer reported damaged packaging.',
            admin: $admin,
        );

        $log = Activity::query()->where('description', 'order.admin_refund')->first();
        $this->assertNotNull($log);
        $this->assertSame($admin->id, $log->causer_id);
        $this->assertSame(3000, $log->properties['amount_cents']);
    }

    public function test_force_cancel_marks_order_admin_forced(): void
    {
        $admin = User::factory()->create();
        $purchase = Purchase::factory()->create(['stripe_payment_intent_id' => 'pi_fc']);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'status' => OrderStatus::Pending,
            'subtotal' => 5000,
            'shipping_cost' => 500,
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->once()
            ->andReturn(Refund::constructFrom(['id' => 're_fc']));
        $this->app->instance(StripeService::class, $stripe);

        app(AdminOrderActions::class)->forceCancel(
            order: $order,
            justification: 'Seller unresponsive past auto-cancel window — manual cancel.',
            admin: $admin,
        );

        $fresh = $order->fresh();
        $this->assertSame(OrderStatus::Cancelled, $fresh->status);
        $this->assertSame(CancellationReason::AdminForced, $fresh->cancellation_reason);
        $this->assertNotNull($fresh->cancelled_at);
    }

    public function test_reverse_transfer_writes_columns_and_logs(): void
    {
        $admin = User::factory()->create();
        $order = Order::factory()->create([
            'subtotal' => 4000,
            'shipping_cost' => 1000,
            'stripe_transfer_id' => 'tr_rev',
            'transferred_at' => now(),
            'transfer_reversed_at' => null,
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('reverseTransfer')->once()
            ->andReturn(TransferReversal::constructFrom(['id' => 'trr_rev']));
        $this->app->instance(StripeService::class, $stripe);

        app(AdminOrderActions::class)->reverseTransfer(
            order: $order,
            amountCents: 5000,
            justification: 'Manual reversal outside dispute flow — chargeback risk.',
            admin: $admin,
        );

        $fresh = $order->fresh();
        $this->assertSame('trr_rev', $fresh->stripe_transfer_reversal_id);
        $this->assertNotNull($fresh->transfer_reversed_at);
    }

    public function test_reverse_transfer_throws_if_no_transfer(): void
    {
        $order = Order::factory()->create(['stripe_transfer_id' => null]);
        $admin = User::factory()->create();

        $this->expectException(\RuntimeException::class);
        app(AdminOrderActions::class)->reverseTransfer(
            order: $order,
            amountCents: 1000,
            justification: 'no transfer to reverse — should throw before calling Stripe.',
            admin: $admin,
        );
    }
}
```

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Implement the service**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Services;

use App\Models\Order;
use App\Models\User;
use App\Modules\Checkout\Services\StripeService;
use App\Support\Enums\CancellationReason;
use App\Support\Enums\OrderCancelledBy;
use App\Support\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;
use RuntimeException;

final class AdminOrderActions
{
    public function __construct(private readonly StripeService $stripe) {}

    public function refund(Order $order, int $amountCents, string $justification, User $admin): void
    {
        $pi = $order->purchase?->stripe_payment_intent_id;
        if (! $pi) {
            throw new RuntimeException('Order has no PaymentIntent to refund.');
        }

        DB::transaction(function () use ($order, $amountCents, $pi, $justification, $admin) {
            $refund = $this->stripe->refundForOrder($pi, $amountCents);

            activity('admin')
                ->causedBy($admin)
                ->performedOn($order)
                ->withProperties([
                    'amount_cents' => $amountCents,
                    'justification' => $justification,
                    'stripe_refund_id' => $refund->id,
                ])
                ->log('order.admin_refund');
        });
    }

    public function forceCancel(Order $order, string $justification, User $admin): void
    {
        if (! in_array($order->status, [OrderStatus::Pending, OrderStatus::Processing], true)) {
            throw new RuntimeException('Order cannot be force-cancelled from its current status.');
        }

        DB::transaction(function () use ($order, $justification, $admin) {
            $pi = $order->purchase?->stripe_payment_intent_id;
            $amount = $order->subtotal + $order->shipping_cost + $order->tax_amount;
            if ($pi && $amount > 0) {
                $this->stripe->refundForOrder($pi, $amount);
            }

            $order->update([
                'status' => OrderStatus::Cancelled,
                'cancelled_by' => OrderCancelledBy::Platform,
                'cancellation_reason' => CancellationReason::AdminForced,
                'cancelled_at' => now(),
            ]);

            activity('admin')
                ->causedBy($admin)
                ->performedOn($order)
                ->withProperties(['justification' => $justification])
                ->log('order.admin_force_cancel');
        });
    }

    public function reverseTransfer(Order $order, int $amountCents, string $justification, User $admin): void
    {
        if (! $order->stripe_transfer_id) {
            throw new RuntimeException('Order has no transfer to reverse.');
        }
        if ($order->transfer_reversed_at !== null) {
            throw new RuntimeException('Transfer already reversed.');
        }

        DB::transaction(function () use ($order, $amountCents, $justification, $admin) {
            $reversal = $this->stripe->reverseTransfer(
                transferId: $order->stripe_transfer_id,
                amountCents: $amountCents,
                idempotencyKey: "admin-standalone-reversal:{$order->id}",
                metadata: ['order_id' => $order->id, 'admin_user_id' => $admin->id],
            );

            $order->update([
                'stripe_transfer_reversal_id' => $reversal->id,
                'transfer_reversed_at' => now(),
            ]);

            activity('admin')
                ->causedBy($admin)
                ->performedOn($order)
                ->withProperties([
                    'amount_cents' => $amountCents,
                    'justification' => $justification,
                ])
                ->log('order.admin_reverse_transfer');
        });
    }
}
```

- [ ] **Step 4: Run and confirm 4/4 PASS**

### Task 2: Show endpoint (`GET /v1/admin/orders/{order}`)

**Files:**
- Update: `api/app/Modules/Admin/Controllers/AdminOrderController.php`
- Create: `api/app/Modules/Admin/Resources/AdminOrderDetail.php`
- Update: `api/app/Modules/Admin/routes.php`
- Test: `api/tests/Feature/Admin/AdminOrderShowTest.php`

The detail resource includes everything in the seller order detail (line items with snapshots, tracking, ship-by, transferred state) plus admin-only fields like `transfer_reversed_at` and the buyer's contact email.

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Purchase;
use App\Models\User;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class AdminOrderShowTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_unauthenticated_returns_401(): void
    {
        $order = Order::factory()->create();
        $this->getJson("/v1/admin/orders/{$order->id}")->assertUnauthorized();
    }

    public function test_admin_gets_full_order_detail(): void
    {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $purchase = Purchase::factory()->create([
            'stripe_payment_intent_id' => 'pi_test',
            'shipping_address' => ['first_name' => 'Jane', 'last_name' => 'Doe'],
        ]);
        $order = Order::factory()->create(['purchase_id' => $purchase->id]);
        OrderItem::factory()->count(2)->create(['order_id' => $order->id]);

        Sanctum::actingAs($admin);

        $this->getJson("/v1/admin/orders/{$order->id}")
            ->assertOk()
            ->assertJsonStructure([
                'data' => [
                    'id',
                    'status',
                    'subtotal',
                    'shipping_cost',
                    'tracking_number',
                    'shipped_at',
                    'transferred_at',
                    'transfer_reversed_at',
                    'stripe_transfer_id',
                    'cancelled_at',
                    'store' => ['id', 'name'],
                    'buyer' => ['first_name', 'last_name'],
                    'items' => [['id', 'title_snapshot', 'price_snapshot']],
                    'purchase' => ['id', 'stripe_payment_intent_id'],
                ],
            ]);
    }
}
```

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Implement `AdminOrderDetail` resource**

```php
<?php

declare(strict_types=1);

namespace App\Modules\Admin\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class AdminOrderDetail extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray(Request $request): array
    {
        $address = is_array($this->purchase?->shipping_address ?? null)
            ? $this->purchase->shipping_address
            : [];

        return [
            'id' => $this->id,
            'purchase_id' => $this->purchase_id,
            'status' => $this->status->value,
            'subtotal' => $this->subtotal,
            'shipping_cost' => $this->shipping_cost,
            'tax_amount' => $this->tax_amount,
            'total' => $this->subtotal + $this->shipping_cost + $this->tax_amount,
            'tracking_number' => $this->tracking_number,
            'tracking_url' => $this->tracking_url,
            'carrier' => $this->carrier,
            'ship_by' => $this->ship_by?->toIso8601String(),
            'shipped_at' => $this->shipped_at?->toIso8601String(),
            'delivered_at' => $this->delivered_at?->toIso8601String(),
            'cancelled_at' => $this->cancelled_at?->toIso8601String(),
            'cancellation_reason' => $this->cancellation_reason?->value,
            'stripe_transfer_id' => $this->stripe_transfer_id,
            'transferred_at' => $this->transferred_at?->toIso8601String(),
            'stripe_transfer_reversal_id' => $this->stripe_transfer_reversal_id,
            'transfer_reversed_at' => $this->transfer_reversed_at?->toIso8601String(),
            'created_at' => $this->created_at->toIso8601String(),
            'store' => [
                'id' => $this->store->id,
                'name' => $this->store->name,
            ],
            'buyer' => [
                'first_name' => $address['first_name'] ?? null,
                'last_name' => $address['last_name'] ?? null,
            ],
            'items' => $this->orderItems->map(fn ($oi) => [
                'id' => $oi->id,
                'item_id' => $oi->item_id,
                'title_snapshot' => $oi->title_snapshot,
                'price_snapshot' => $oi->price_snapshot,
                'image_url_snapshot' => $oi->image_url_snapshot,
            ]),
            'purchase' => [
                'id' => $this->purchase->id,
                'stripe_payment_intent_id' => $this->purchase->stripe_payment_intent_id,
                'shipping_address' => $address,
            ],
        ];
    }
}
```

- [ ] **Step 4: Add `show` to `AdminOrderController`**

```php
public function show(Order $order): JsonResponse
{
    $order->load(['store:id,name', 'orderItems', 'purchase']);

    return response()->json([
        'data' => new AdminOrderDetail($order),
    ]);
}
```

(Add the use-imports for `Order` and `AdminOrderDetail`.)

- [ ] **Step 5: Wire the route**

```php
Route::get('/orders/{order}', [AdminOrderController::class, 'show']);
```

- [ ] **Step 6: Run and confirm PASS**

### Task 3: Three action endpoints

**Files:**
- Update: `api/app/Modules/Admin/Controllers/AdminOrderController.php`
- Create: `api/app/Modules/Admin/Requests/AdminRefundRequest.php`
- Create: `api/app/Modules/Admin/Requests/JustificationRequest.php` (shared by force-cancel + reverse-transfer)
- Update: `api/app/Modules/Admin/routes.php`
- Test: `api/tests/Feature/Admin/AdminOrderActionsEndpointTest.php`

- [ ] **Step 1: Write the endpoint test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Admin;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\User;
use App\Modules\Checkout\Services\StripeService;
use App\Support\Enums\CancellationReason;
use App\Support\Enums\OrderStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Mockery;
use Stripe\Refund;
use Stripe\TransferReversal;
use Tests\TestCase;

class AdminOrderActionsEndpointTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    private function admin(): User
    {
        $u = User::factory()->create();
        $u->assignRole('admin');

        return $u;
    }

    public function test_refund_endpoint_validates_amount_and_justification(): void
    {
        Sanctum::actingAs($this->admin());
        $order = Order::factory()->create();

        // missing amount
        $this->postJson("/v1/admin/orders/{$order->id}/refund", [
            'justification' => str_repeat('x', 30),
        ])->assertStatus(422)->assertJsonValidationErrors(['amount_cents']);

        // short justification
        $this->postJson("/v1/admin/orders/{$order->id}/refund", [
            'amount_cents' => 1000,
            'justification' => 'short',
        ])->assertStatus(422)->assertJsonValidationErrors(['justification']);
    }

    public function test_refund_endpoint_succeeds(): void
    {
        $admin = $this->admin();
        $purchase = Purchase::factory()->create(['stripe_payment_intent_id' => 'pi_ok']);
        $order = Order::factory()->create(['purchase_id' => $purchase->id]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->once()
            ->andReturn(Refund::constructFrom(['id' => 're_x']));
        $this->app->instance(StripeService::class, $stripe);

        Sanctum::actingAs($admin);

        $this->postJson("/v1/admin/orders/{$order->id}/refund", [
            'amount_cents' => 2500,
            'justification' => 'Goodwill refund for poor service experience.',
        ])->assertOk();
    }

    public function test_force_cancel_endpoint(): void
    {
        $admin = $this->admin();
        $purchase = Purchase::factory()->create(['stripe_payment_intent_id' => 'pi_fc']);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'status' => OrderStatus::Pending,
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->once()
            ->andReturn(Refund::constructFrom(['id' => 're_fc']));
        $this->app->instance(StripeService::class, $stripe);

        Sanctum::actingAs($admin);

        $this->postJson("/v1/admin/orders/{$order->id}/force-cancel", [
            'justification' => 'Seller unresponsive past auto-cancel window.',
        ])->assertOk();

        $this->assertSame(OrderStatus::Cancelled, $order->fresh()->status);
        $this->assertSame(CancellationReason::AdminForced, $order->fresh()->cancellation_reason);
    }

    public function test_force_cancel_blocks_already_cancelled_orders(): void
    {
        Sanctum::actingAs($this->admin());
        $order = Order::factory()->create(['status' => OrderStatus::Cancelled]);

        $this->postJson("/v1/admin/orders/{$order->id}/force-cancel", [
            'justification' => 'Trying to cancel an already-cancelled order.',
        ])->assertStatus(409);
    }

    public function test_reverse_transfer_endpoint(): void
    {
        $admin = $this->admin();
        $order = Order::factory()->create([
            'subtotal' => 4000,
            'shipping_cost' => 1000,
            'stripe_transfer_id' => 'tr_x',
            'transferred_at' => now(),
            'transfer_reversed_at' => null,
        ]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('reverseTransfer')->once()
            ->andReturn(TransferReversal::constructFrom(['id' => 'trr_x']));
        $this->app->instance(StripeService::class, $stripe);

        Sanctum::actingAs($admin);

        $this->postJson("/v1/admin/orders/{$order->id}/reverse-transfer", [
            'justification' => 'Standalone reversal — buyer disputed off-platform.',
        ])->assertOk();

        $this->assertSame('trr_x', $order->fresh()->stripe_transfer_reversal_id);
    }

    public function test_reverse_transfer_409_if_already_reversed(): void
    {
        Sanctum::actingAs($this->admin());
        $order = Order::factory()->create([
            'stripe_transfer_id' => 'tr_done',
            'transferred_at' => now(),
            'stripe_transfer_reversal_id' => 'trr_done',
            'transfer_reversed_at' => now(),
        ]);

        $this->postJson("/v1/admin/orders/{$order->id}/reverse-transfer", [
            'justification' => 'Trying to double-reverse — should be blocked.',
        ])->assertStatus(409);
    }

    public function test_non_admin_cannot_call_actions(): void
    {
        Sanctum::actingAs(User::factory()->create());
        $order = Order::factory()->create();

        $this->postJson("/v1/admin/orders/{$order->id}/refund", [
            'amount_cents' => 100,
            'justification' => str_repeat('x', 30),
        ])->assertForbidden();
    }
}
```

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Create the request classes**

`AdminRefundRequest.php`:

```php
<?php
declare(strict_types=1);
namespace App\Modules\Admin\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'amount_cents' => ['required', 'integer', 'min:1'],
            'justification' => ['required', 'string', 'min:20'],
        ];
    }
}
```

`JustificationRequest.php`:

```php
<?php
declare(strict_types=1);
namespace App\Modules\Admin\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return ['justification' => ['required', 'string', 'min:20']];
    }
}
```

- [ ] **Step 4: Add the controller actions**

```php
public function refund(
    AdminRefundRequest $request,
    Order $order,
    AdminOrderActions $actions,
): JsonResponse {
    $actions->refund(
        order: $order,
        amountCents: (int) $request->validated('amount_cents'),
        justification: $request->validated('justification'),
        admin: $request->user(),
    );

    return response()->json(['data' => new AdminOrderDetail($order->fresh(['store', 'orderItems', 'purchase']))]);
}

public function forceCancel(
    JustificationRequest $request,
    Order $order,
    AdminOrderActions $actions,
): JsonResponse {
    try {
        $actions->forceCancel(
            order: $order,
            justification: $request->validated('justification'),
            admin: $request->user(),
        );
    } catch (\RuntimeException $e) {
        abort(409, $e->getMessage());
    }

    return response()->json(['data' => new AdminOrderDetail($order->fresh(['store', 'orderItems', 'purchase']))]);
}

public function reverseTransfer(
    JustificationRequest $request,
    Order $order,
    AdminOrderActions $actions,
): JsonResponse {
    try {
        $actions->reverseTransfer(
            order: $order,
            amountCents: $order->subtotal + $order->shipping_cost,
            justification: $request->validated('justification'),
            admin: $request->user(),
        );
    } catch (\RuntimeException $e) {
        $code = str_contains($e->getMessage(), 'already reversed') ? 409 : 422;
        abort($code, $e->getMessage());
    }

    return response()->json(['data' => new AdminOrderDetail($order->fresh(['store', 'orderItems', 'purchase']))]);
}
```

- [ ] **Step 5: Wire routes**

```php
Route::post('/orders/{order}/refund', [AdminOrderController::class, 'refund']);
Route::post('/orders/{order}/force-cancel', [AdminOrderController::class, 'forceCancel']);
Route::post('/orders/{order}/reverse-transfer', [AdminOrderController::class, 'reverseTransfer']);
```

- [ ] **Step 6: Run and confirm 7/7 PASS**

---

## Phase B — Buyer / seller notifications

### Task 4: `BuyerRefundIssuedNotification`

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/BuyerRefundIssuedNotification.php`
- Update: `api/app/Modules/Admin/Services/AdminOrderActions.php` (dispatch)
- Test: `api/tests/Feature/Notifications/BuyerRefundIssuedNotificationTest.php`

The notification fans out to the buyer via the database channel + email. Category: `orders`. Body mentions the amount and the order id.

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Notifications;

use App\Models\Order;
use App\Models\Purchase;
use App\Models\User;
use App\Modules\Admin\Services\AdminOrderActions;
use App\Modules\Checkout\Services\StripeService;
use App\Modules\Notifications\Notifications\BuyerRefundIssuedNotification;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Mockery;
use Stripe\Refund;
use Tests\TestCase;

class BuyerRefundIssuedNotificationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        $this->seed(RoleAndPermissionSeeder::class);
    }

    public function test_admin_refund_sends_buyer_notification(): void
    {
        Notification::fake();

        $admin = User::factory()->create();
        $buyer = User::factory()->create();
        $purchase = Purchase::factory()->create([
            'user_id' => $buyer->id,
            'stripe_payment_intent_id' => 'pi_n',
        ]);
        $order = Order::factory()->create(['purchase_id' => $purchase->id]);

        $stripe = Mockery::mock(StripeService::class);
        $stripe->shouldReceive('refundForOrder')->andReturn(Refund::constructFrom(['id' => 're_n']));
        $this->app->instance(StripeService::class, $stripe);

        app(AdminOrderActions::class)->refund(
            order: $order,
            amountCents: 2500,
            justification: 'Goodwill refund for poor experience.',
            admin: $admin,
        );

        Notification::assertSentTo($buyer, BuyerRefundIssuedNotification::class);
    }
}
```

- [ ] **Step 2: Run, confirm failure**

- [ ] **Step 3: Create the notification class**

Reference existing notifications (e.g., `BuyerOrderAutoCancelledNotification`) for the boilerplate. Required pieces:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Notifications\Notifications;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class BuyerRefundIssuedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public readonly Order $order,
        public readonly int $amountCents,
    ) {}

    public function via(object $notifiable): array
    {
        return ['mail', 'database'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        $dollars = number_format($this->amountCents / 100, 2);

        return (new MailMessage)
            ->subject("Refund issued — Order #{$this->shortId()}")
            ->line("A refund of \${$dollars} has been issued for your order.")
            ->line('It may take a few business days to appear on your statement.')
            ->action('View order', config('app.frontend_url')."/purchases/{$this->order->purchase_id}");
    }

    public function toDatabase(object $notifiable): array
    {
        return [
            'category' => 'orders',
            'title' => 'Refund issued',
            'body' => "Refund of \$".number_format($this->amountCents / 100, 2)." for order #{$this->shortId()}",
            'cta_url' => "/purchases/{$this->order->purchase_id}",
        ];
    }

    private function shortId(): string
    {
        return substr($this->order->id, 0, 8);
    }
}
```

- [ ] **Step 4: Dispatch from the service**

In `AdminOrderActions::refund` (and `forceCancel`, since it issues a refund too), inside the DB transaction, after the activity log entry:

```php
$buyer = $order->purchase?->user;
if ($buyer) {
    $buyer->notify(new BuyerRefundIssuedNotification($order, $amountCents));
}
```

(For `forceCancel`, the amountCents is the full subtotal+shipping+tax already computed.)

- [ ] **Step 5: Run and confirm PASS**

### Task 5: `SellerOrderForceCancelledNotification`

**Files:**
- Create: `api/app/Modules/Notifications/Notifications/SellerOrderForceCancelledNotification.php`
- Update: `api/app/Modules/Admin/Services/AdminOrderActions.php`
- Test: `api/tests/Feature/Notifications/SellerOrderForceCancelledNotificationTest.php`

Same shape as Task 4, but targets the store owner with category `orders` and a different body. Test asserts `Notification::assertSentTo($storeOwner, SellerOrderForceCancelledNotification::class)` after `forceCancel` runs.

- [ ] **Step 1: Write the failing test (mirror Task 4's shape, with the seller as recipient)**
- [ ] **Step 2: Implement the notification**
- [ ] **Step 3: Dispatch from `forceCancel` after the activity log**
- [ ] **Step 4: Run and confirm PASS**

---

## Phase C — OpenAPI + types + api-client

### Task 6: Document the four new paths and regenerate types

**Files:**
- Update: `api/contracts/openapi.yaml`
- Update: `web/contracts/openapi.yaml` (via `bin/sync-openapi.sh`)
- Update: `web/packages/types/src/generated.ts` (regenerated)

Add four operations under the existing admin block:

- `GET /v1/admin/orders/{order}` (operationId `adminShowOrder`) → `AdminOrderDetail` schema
- `POST /v1/admin/orders/{order}/refund` (operationId `adminOrderRefund`) → request schema with `amount_cents` + `justification` ≥ 20 chars
- `POST /v1/admin/orders/{order}/force-cancel` (operationId `adminOrderForceCancel`) → `JustificationBody` schema
- `POST /v1/admin/orders/{order}/reverse-transfer` (operationId `adminOrderReverseTransfer`) → `JustificationBody` schema

New component schemas:
- `AdminOrderDetail` (extends the seller `OrderDetail` shape with `transfer_reversed_at`, `stripe_transfer_reversal_id`, and a `buyer` block with `first_name`/`last_name` from `purchase.shipping_address`)
- `JustificationBody` (`{ justification: string, minLength: 20 }`)
- `AdminRefundBody` (`{ amount_cents: int >= 1, justification: string, minLength: 20 }`)

- [ ] **Step 1: Append the YAML and validate** — `python3 -c "import yaml; yaml.safe_load(open(...))"`
- [ ] **Step 2: Sync to web** — `cd alqove-web && ./bin/sync-openapi.sh && npm run build:types`

### Task 7: Extend `@alqove/api-client` admin namespace

**Files:** `web/packages/api-client/src/endpoints/admin.ts`, `web/packages/api-client/src/index.ts`

Add to the admin namespace:

```ts
export interface AdminOrderDetail {
  id: string;
  status: string;
  subtotal: number;
  shipping_cost: number;
  tax_amount: number;
  total: number;
  tracking_number: string | null;
  tracking_url: string | null;
  carrier: string | null;
  ship_by: string | null;
  shipped_at: string | null;
  delivered_at: string | null;
  cancelled_at: string | null;
  cancellation_reason: string | null;
  stripe_transfer_id: string | null;
  transferred_at: string | null;
  stripe_transfer_reversal_id: string | null;
  transfer_reversed_at: string | null;
  store: { id: string; name: string };
  buyer: { first_name: string | null; last_name: string | null };
  items: { id: string; title_snapshot: string; price_snapshot: number; image_url_snapshot: string | null }[];
  purchase: { id: string; stripe_payment_intent_id: string | null; shipping_address: Record<string, unknown> };
  created_at: string;
}

// Inside createAdminEndpoints():
showOrder(orderId: string) { return client.get<ApiResponse<AdminOrderDetail>>(`/v1/admin/orders/${orderId}`); },
refundOrder(orderId: string, amountCents: number, justification: string) {
  return client.post<ApiResponse<AdminOrderDetail>>(
    `/v1/admin/orders/${orderId}/refund`,
    { amount_cents: amountCents, justification },
  );
},
forceCancelOrder(orderId: string, justification: string) {
  return client.post<ApiResponse<AdminOrderDetail>>(
    `/v1/admin/orders/${orderId}/force-cancel`,
    { justification },
  );
},
reverseOrderTransfer(orderId: string, justification: string) {
  return client.post<ApiResponse<AdminOrderDetail>>(
    `/v1/admin/orders/${orderId}/reverse-transfer`,
    { justification },
  );
},
```

Re-export `AdminOrderDetail` from `index.ts`.

- [ ] **Step 1: Add the types and methods**
- [ ] **Step 2: `npm run typecheck --workspace=@alqove/api-client`** — clean

---

## Phase D — Frontend

### Task 8: `useAdminOrder` + mutation hooks

**File:** `web/src/lib/queries/use-admin.ts`

```ts
export function useAdminOrder(orderId: string | null) {
  return useQuery({
    queryKey: ['admin', 'order', orderId],
    queryFn: () => api.admin.showOrder(orderId!),
    enabled: Boolean(orderId),
  });
}

export function useAdminRefund(orderId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ amountCents, justification }: { amountCents: number; justification: string }) =>
      api.admin.refundOrder(orderId, amountCents, justification),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'order', orderId] });
      qc.invalidateQueries({ queryKey: ['admin', 'orders'] });
      qc.invalidateQueries({ queryKey: ['admin', 'dashboard'] });
    },
  });
}

export function useAdminForceCancel(orderId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (justification: string) => api.admin.forceCancelOrder(orderId, justification),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['admin', 'order', orderId] });
      qc.invalidateQueries({ queryKey: ['admin', 'orders'] });
    },
  });
}

export function useAdminReverseTransfer(orderId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (justification: string) => api.admin.reverseOrderTransfer(orderId, justification),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'order', orderId] }),
  });
}
```

### Task 9: Admin order detail page

**Files:**
- Create: `web/src/app/(admin)/admin/orders/[id]/page.tsx`
- Create: `web/src/app/(admin)/admin/orders/[id]/order-detail-client.tsx`
- Create: `web/src/app/(admin)/admin/orders/[id]/__tests__/order-detail-client.test.tsx`

The page renders panels in this order:

```
Header: Order #abc123 · status · placed 3d ago
ADMIN ACTIONS                                        ← admin-only strip
[Issue refund] [Force cancel] [Reverse transfer]
ORDER SUMMARY     |  BUYER & SHIPPING
LINE ITEMS
TIMELINE (Placed → Shipped → Delivered)
TRANSFER STATE (transferred / reversed / no transfer)
```

Each action button opens a `ConfirmWithJustificationDialog`. Refund's dialog also shows a number input for `amount_cents` (default = full order total). Force-cancel + reverse-transfer use the standard dialog as-is.

- [ ] **Step 1: Write three failing tests**

```tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { OrderDetailClient } from '../order-detail-client';

const showMock = vi.fn();
const refundMock = vi.fn();
const forceCancelMock = vi.fn();
const reverseMock = vi.fn();
vi.mock('@/lib/api', () => ({
  api: {
    admin: {
      showOrder: (...a: unknown[]) => showMock(...a),
      refundOrder: (...a: unknown[]) => refundMock(...a),
      forceCancelOrder: (...a: unknown[]) => forceCancelMock(...a),
      reverseOrderTransfer: (...a: unknown[]) => reverseMock(...a),
    },
  },
}));

function wrap(node: React.ReactNode) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{node}</QueryClientProvider>;
}

const detail = {
  id: 'abc123',
  status: 'pending',
  subtotal: 5000,
  shipping_cost: 500,
  tax_amount: 0,
  total: 5500,
  tracking_number: null,
  tracking_url: null,
  carrier: null,
  ship_by: null,
  shipped_at: null,
  delivered_at: null,
  cancelled_at: null,
  cancellation_reason: null,
  stripe_transfer_id: 'tr_x',
  transferred_at: '2026-05-01T00:00:00Z',
  stripe_transfer_reversal_id: null,
  transfer_reversed_at: null,
  store: { id: 's1', name: 'Revive' },
  buyer: { first_name: 'Jane', last_name: 'Doe' },
  items: [
    { id: 'oi1', item_id: 'item-1', title_snapshot: 'Tee', price_snapshot: 2500, image_url_snapshot: null },
    { id: 'oi2', item_id: 'item-2', title_snapshot: 'Jacket', price_snapshot: 2500, image_url_snapshot: null },
  ],
  purchase: { id: 'p1', stripe_payment_intent_id: 'pi_1', shipping_address: {} },
  created_at: '2026-05-04T00:00:00Z',
};

describe('OrderDetailClient (admin)', () => {
  beforeEach(() => {
    showMock.mockReset();
    refundMock.mockReset();
    forceCancelMock.mockReset();
    reverseMock.mockReset();
  });

  it('refund flow: amount input + justification → calls refundOrder', async () => {
    showMock.mockResolvedValue({ data: detail });
    refundMock.mockResolvedValue({ data: detail });
    render(wrap(<OrderDetailClient orderId="abc123" />));
    await waitFor(() => expect(screen.getByText('Tee')).toBeInTheDocument());

    fireEvent.click(screen.getByRole('button', { name: /Issue refund/ }));
    fireEvent.change(screen.getByLabelText('Refund amount (cents)'), { target: { value: '3000' } });
    fireEvent.change(screen.getByLabelText('Justification'), {
      target: { value: 'Customer reported damaged packaging — partial refund.' },
    });
    fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));

    await waitFor(() => expect(refundMock).toHaveBeenCalled());
    const [orderId, amount, just] = refundMock.mock.calls[0];
    expect(orderId).toBe('abc123');
    expect(amount).toBe(3000);
    expect(just).toMatch(/damaged packaging/);
  });

  it('force-cancel flow', async () => {
    showMock.mockResolvedValue({ data: detail });
    forceCancelMock.mockResolvedValue({ data: detail });
    render(wrap(<OrderDetailClient orderId="abc123" />));
    await waitFor(() => expect(screen.getByText('Tee')).toBeInTheDocument());

    fireEvent.click(screen.getByRole('button', { name: /Force cancel/ }));
    fireEvent.change(screen.getByLabelText('Justification'), {
      target: { value: 'Seller unresponsive past auto-cancel window.' },
    });
    fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));

    await waitFor(() => expect(forceCancelMock).toHaveBeenCalled());
  });

  it('reverse-transfer disabled when transfer_reversed_at is set', async () => {
    showMock.mockResolvedValue({
      data: { ...detail, transfer_reversed_at: '2026-05-02T00:00:00Z', stripe_transfer_reversal_id: 'trr_done' },
    });
    render(wrap(<OrderDetailClient orderId="abc123" />));
    await waitFor(() => expect(screen.getByText('Tee')).toBeInTheDocument());
    expect(screen.getByRole('button', { name: /Reverse transfer/ })).toBeDisabled();
  });
});
```

- [ ] **Step 2: Implement `OrderDetailClient`**

Sketch (filling in panels with reasonable Tailwind):

```tsx
'use client';

import { useState } from 'react';
import {
  useAdminOrder,
  useAdminRefund,
  useAdminForceCancel,
  useAdminReverseTransfer,
} from '@/lib/queries/use-admin';
import { ConfirmWithJustificationDialog } from '@/components/admin/confirm-with-justification-dialog';

type Action = 'refund' | 'force-cancel' | 'reverse';

function fmt(cents: number) { return `$${(cents / 100).toFixed(2)}`; }

export function OrderDetailClient({ orderId }: { orderId: string }) {
  const { data, isLoading, isError } = useAdminOrder(orderId);
  const detail = data?.data;
  const refund = useAdminRefund(orderId);
  const forceCancel = useAdminForceCancel(orderId);
  const reverse = useAdminReverseTransfer(orderId);

  const [action, setAction] = useState<Action | null>(null);
  const [refundAmount, setRefundAmount] = useState<string>('');

  if (isLoading) return <div className="text-sm text-slate-400">Loading…</div>;
  if (isError || !detail) return <p className="rounded bg-red-50 p-3 text-sm text-red-700">Couldn&apos;t load this order.</p>;

  const cancelled = !!detail.cancelled_at;
  const reversed = !!detail.transfer_reversed_at;

  const onConfirm = (justification: string) => {
    if (action === 'refund') {
      refund.mutate({ amountCents: Number(refundAmount), justification }, { onSettled: () => setAction(null) });
    } else if (action === 'force-cancel') {
      forceCancel.mutate(justification, { onSettled: () => setAction(null) });
    } else if (action === 'reverse') {
      reverse.mutate(justification, { onSettled: () => setAction(null) });
    }
  };

  return (
    <div>
      <h1 className="text-2xl font-bold text-slate-900">Order #{detail.id.slice(0, 8)}</h1>
      <p className="mt-1 text-sm text-slate-500">{detail.store.name} · {detail.status}</p>

      <section className="mt-4 rounded-lg border border-slate-200 bg-white p-4">
        <h2 className="font-semibold text-sm text-slate-700">Admin actions</h2>
        <div className="mt-2 flex gap-2">
          <button
            onClick={() => { setAction('refund'); setRefundAmount(String(detail.total)); }}
            className="rounded-md border border-slate-300 px-3 py-1 text-sm hover:bg-slate-50"
          >
            Issue refund
          </button>
          <button
            onClick={() => setAction('force-cancel')}
            disabled={cancelled}
            className="rounded-md border border-slate-300 px-3 py-1 text-sm hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            Force cancel
          </button>
          <button
            onClick={() => setAction('reverse')}
            disabled={reversed || !detail.stripe_transfer_id}
            className="rounded-md border border-slate-300 px-3 py-1 text-sm hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            Reverse transfer
          </button>
        </div>
      </section>

      <section className="mt-4 grid grid-cols-2 gap-4">
        <div className="rounded-lg border border-slate-200 bg-white p-4">
          <h2 className="font-semibold text-sm text-slate-700">Order summary</h2>
          <ul className="mt-2 text-sm text-slate-600 space-y-1">
            <li>Subtotal: {fmt(detail.subtotal)}</li>
            <li>Shipping: {fmt(detail.shipping_cost)}</li>
            <li className="font-semibold">Total: {fmt(detail.total)}</li>
          </ul>
        </div>
        <div className="rounded-lg border border-slate-200 bg-white p-4">
          <h2 className="font-semibold text-sm text-slate-700">Buyer</h2>
          <p className="mt-2 text-sm text-slate-600">
            {detail.buyer.first_name} {detail.buyer.last_name}
          </p>
        </div>
      </section>

      <section className="mt-4 rounded-lg border border-slate-200 bg-white p-4">
        <h2 className="font-semibold text-sm text-slate-700">Line items</h2>
        <ul className="mt-2 text-sm text-slate-600 divide-y divide-slate-100">
          {detail.items.map((it) => (
            <li key={it.id} className="py-2 flex justify-between">
              <span>{it.title_snapshot}</span>
              <span>{fmt(it.price_snapshot)}</span>
            </li>
          ))}
        </ul>
      </section>

      <section className="mt-4 rounded-lg border border-slate-200 bg-white p-4">
        <h2 className="font-semibold text-sm text-slate-700">Transfer state</h2>
        <p className="mt-2 text-sm text-slate-600">
          {detail.stripe_transfer_id
            ? reversed
              ? `Reversed (${detail.stripe_transfer_reversal_id})`
              : `Transferred (${detail.stripe_transfer_id})`
            : 'Not transferred yet.'}
        </p>
      </section>

      <ConfirmWithJustificationDialog
        open={action === 'force-cancel'}
        title="Force-cancel this order?"
        description="The order will be marked Cancelled and a full refund will be issued."
        confirmLabel="Force cancel"
        onConfirm={onConfirm}
        onCancel={() => setAction(null)}
        isPending={forceCancel.isPending}
      />
      <ConfirmWithJustificationDialog
        open={action === 'reverse'}
        title="Reverse the Stripe Transfer?"
        description="The transferred funds will be clawed back from the seller's connected account."
        confirmLabel="Reverse transfer"
        onConfirm={onConfirm}
        onCancel={() => setAction(null)}
        isPending={reverse.isPending}
      />

      {action === 'refund' && (
        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
          <div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
            <h2 className="text-lg font-bold text-slate-900">Issue a refund</h2>
            <label className="mt-3 block text-sm">
              <span className="font-medium text-slate-700">Refund amount (cents)</span>
              <input
                aria-label="Refund amount (cents)"
                type="number"
                value={refundAmount}
                onChange={(e) => setRefundAmount(e.target.value)}
                className="mt-1 w-full rounded-md border border-slate-300 p-2 text-sm"
              />
            </label>
            <RefundJustification onConfirm={onConfirm} onCancel={() => setAction(null)} pending={refund.isPending} />
          </div>
        </div>
      )}
    </div>
  );
}

function RefundJustification({ onConfirm, onCancel, pending }: { onConfirm: (j: string) => void; onCancel: () => void; pending: boolean }) {
  const [text, setText] = useState('');
  const disabled = text.trim().length < 20 || pending;
  return (
    <>
      <label className="mt-3 block text-sm">
        <span className="font-medium text-slate-700">Justification</span>
        <textarea
          aria-label="Justification"
          value={text}
          onChange={(e) => setText(e.target.value)}
          className="mt-1 w-full rounded-md border border-slate-300 p-2 text-sm"
          rows={4}
          placeholder="≥ 20 characters — recorded in the audit log"
        />
      </label>
      <div className="mt-4 flex gap-2">
        <button onClick={onCancel} className="flex-1 rounded-md border border-slate-300 px-3 py-2 text-sm">Cancel</button>
        <button disabled={disabled} onClick={() => onConfirm(text.trim())} className="flex-1 rounded-md bg-slate-900 px-3 py-2 text-sm text-white disabled:opacity-50">
          {pending ? 'Working…' : 'Confirm'}
        </button>
      </div>
    </>
  );
}
```

Page wrapper:

```tsx
import { use } from 'react';
import { OrderDetailClient } from './order-detail-client';

export const metadata = { title: 'Order · Admin' };

export default function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = use(params);
  return <OrderDetailClient orderId={id} />;
}
```

- [ ] **Step 3: Run all three tests; iterate to PASS**

### Task 10: Wire row click on `/admin/orders` to the detail page

**File:** `web/src/app/(admin)/admin/orders/page.tsx`

Wrap each order ID cell in a `<Link href={`/admin/orders/${order.id}`}>`.

- [ ] **Step 1: One-line change in the existing `AdminOrders` component (the JSX for the order id cell)**
- [ ] **Step 2: Re-run the existing `orders-page.test.tsx` to confirm no regression**

---

## Phase E — Wrap-up

### Task 11: Full sweep

- [ ] **Step 1: Backend tests** — `docker compose exec -T laravel.test php artisan test`. Expected: previous 357 + ~14 (4 service + 7 endpoint + 1 buyer notif + 1 seller notif + 1 show endpoint) ≥ 371.
- [ ] **Step 2: Backend pint** — auto-fix new files: `./vendor/bin/pint app/Modules/Admin app/Modules/Notifications/Notifications/BuyerRefundIssuedNotification.php app/Modules/Notifications/Notifications/SellerOrderForceCancelledNotification.php tests/Feature/Admin tests/Feature/Notifications`.
- [ ] **Step 3: Web typecheck** — `npm run typecheck`.
- [ ] **Step 4: Web lint** — `npm run lint`.
- [ ] **Step 5: Web tests** — `npm run test`. Expected: previous 126 + 3 (admin order detail) ≥ 129 passing (skipped count unchanged).
- [ ] **Step 6: Manual QA**
  - Seed an admin user, a Purchase + Order, simulate a `transferred_at` value.
  - Visit `/admin/orders` — click into a row → land on the detail page.
  - Try Issue refund (verify the typed amount becomes the request body).
  - Try Force cancel (verify the order moves to `cancelled` with `cancellation_reason=admin_forced`).
  - Try Reverse transfer (verify columns populate; button disables).
  - Verify activity_log rows appear.
  - Verify the buyer's `notifications` table receives a `BuyerRefundIssuedNotification` row.

### Task 12: Commit + push

Two commits, mirrored across both repos (alqove-api first, then alqove-web).

- [ ] **API commit:** `feat(admin): order actions — refund, force-cancel, reverse-transfer + audit log + notifications`
- [ ] **Web commit:** `feat(admin): order detail page with refund / force-cancel / reverse-transfer actions`

---

## Open items deferred to follow-up plans

- **Idempotency-key middleware on the new POST endpoints.** The existing `IdempotencyMiddleware` is already in the API stack and applies; no plan-level wiring needed beyond the standard header. Document the requirement in OpenAPI for clients.
- **Email templates** for both new notifications use plain text via `MailMessage` for now; richer markdown templates land alongside Plan 4 (admin inbox).
- **`transferred_at` is null but `stripe_transfer_id` is set** edge case — defensive guard in `reverseTransfer` is left for the audit-extension task in Plan 4.
- **Per-Item refund granularity** (refund only one OrderItem out of many) is explicitly out of scope; the spec covers full or partial Order-level refunds only.
