# Cancel Single OrderItem — Structure Plan

Goal: allow a client to cancel **a single item** inside a sub-order (`Order`), not just the whole sub-order. Money has to be refunded for that item only, inventory must be returned, the parent `Order` totals and the `Payment` row must be re-balanced, and the cascade rules (last item → cancel sub-order → cancel order group) must keep working without double refunds.

---

## 1. Domain model

```
OrderGroup  (1) ──< Order (sub-order) >── (many) OrderItem
     │                                          │
     │                                          └──< InventoryMovement (SALE / RETURN, linked by order_item_id)
     │
     └── morphOne Payment   (holds total, subtotal, vat_amount, commission_amount,
                             vendor_gross, vendor_payout, discount, shipping_cost,
                             refund_amount, paid_amount)
```

Today only `Order` has a state machine. `OrderItem` is flat. This plan adds an item-level lifecycle that mirrors the sub-order pattern (`States/Order/SubOrder/*`) so refund + inventory logic stays consistent.

> Shipping is treated as **non-refundable** at every level. Item-level cancel never touches shipping (items have no shipping share). Sub-order and order-group cancels keep `payment.shipping_cost` frozen and refund only `total - shipping_cost`.

---

## 2. Files to create

### 2.1 Migration — add status to `order_items`

`database/migrations/2026_xx_xx_xxxxxx_add_status_to_order_items_table.php`

```php
Schema::table('order_items', function (Blueprint $table) {
    $table->string('status')->default(\App\Enums\OrderStatus::PENDING->value)->after('total');
    $table->string('cancel_by')->nullable()->after('status');
    $table->string('cancel_reason')->nullable()->after('cancel_by');
    $table->timestamp('cancelled_at')->nullable()->after('cancel_reason');
    $table->index(['order_id', 'status']);
});
```

Reuse `OrderStatus` enum (only `PENDING` and `CANCELLED` will actually be used at item level for now).

### 2.2 State machine for OrderItem (mirror SubOrder pattern)

```
app/States/Order/OrderItem/
├── Contracts/
│   ├── IOrderItemState.php       // interface: handle(): OrderItem; canCancel(): bool;
│   └── OrderItemState.php        // abstract base, holds OrderItem, exposes order(), client(), payment()
├── PendingState.php              // canCancel() => true
└── CancelledState.php            // handle(): re-balance Order + Payment + wallet + inventory, cascade
```

`CancelledState::handle()` is the heart of the feature — see §4 for the algorithm.

### 2.3 Update `OrderItem` model

`app/Models/OrderItem.php`

- cast `status => OrderStatus::class`, `cancel_by => UserType::class`
- private `IOrderItemState $state`, `setState()` and `booted()` `retrieved` hook (same shape as `Order::booted`)
- methods on the model:
  - `cancel(?string $reason, ?UserType $by = null): bool`
  - `updateStatus(OrderStatus $s): void`
  - `protected startState(OrderStatus $s): void`
- helper attribute `getCanCancelAttribute()` — returns `$this->status === OrderStatus::PENDING && $this->order->can_cancel`

### 2.4 Request

`app/Http/Requests/Api/App/Order/CancelOrderItemRequest.php`

Validates:
- `reason` optional string max 255
- route param `id` (sub-order) and `item` (order_item)
- the item belongs to the sub-order, the sub-order belongs to the authed client
- the item is `PENDING`
- the parent sub-order is `PENDING` (cannot cancel item once vendor is processing)
- `$order->orderGroup->can_cancel` is true (no coupon, no deferred — same gate as sub-order cancel)

### 2.5 Controller method

`app/Http/Controllers/Api/App/Client/Order/OrderController.php`

```php
public function cancelItem(CancelOrderItemRequest $request, int $id, int $itemId)
{
    $item = OrderItem::with('order.orderGroup')
        ->whereHas('order', fn($q) => $q->where('id', $id))
        ->findOrFail($itemId);

    DB::beginTransaction();
    try {
        $reason = $request->validated('reason');
        $ok     = $item->cancel($reason, UserType::CLIENT);

        if (!$ok) {
            DB::rollBack();
            return json(null, trans('Item cannot be cancelled in current state'), 'error', 400);
        }

        DB::commit();
        return json(new SubOrderResource($item->order->fresh('items')),
                    trans('Item cancelled successfully'), 'success', 200);
    } catch (\Throwable $e) {
        DB::rollBack();
        log_activity('error', ['message' => 'Cancel order item failed', 'error' => $e->getMessage()]);
        return json(null, __('Something went wrong. Please try again later.'), 'error', 500);
    }
}
```

### 2.6 Route

`routes/api/app/client.php`

```php
Route::patch('orders/{id}/items/{item}/cancel',
    [OrderController::class, 'cancelItem'])->name('orders.items.cancel');
```

### 2.7 Notification messages

`app/Services/General/NotificationMessages.php` — add three helpers, mirroring `*CancelSubOrder`:

- `adminCancelOrderItem($item)`
- `vendorCancelOrderItem($item)`
- `clientCancelOrderItem($item)`

### 2.8 Resource (optional)

If `OrderItemResource` does not surface `status` / `can_cancel`, add them so the client UI can show the cancel button and reflect the cancelled state.

---

## 3. Files to modify

### 3.1 `app/States/Order/SubOrder/CancelledState.php`

When a whole sub-order is cancelled, items already cancelled at item-level must be skipped — otherwise we double-refund and double-return inventory.

- inventory loop: `if ($orderItem->status === OrderStatus::CANCELLED) continue;`
- the Payment decrement and the wallet deposit currently use `$this->order->total`. After per-item cancels the sub-order's `total` will already have been decremented (see §4). So the existing math still holds — we just skip the items that already returned inventory.

### 3.2 `app/Models/Order.php`

Add an `active_items` relation (items where status != CANCELLED) — used by the cascade check and by the `Order::cancel` flow if you want to recompute totals defensively.

### 3.3 `app/Observers/OrderItemObserver.php` (new, optional)

If you want item-creation logic later (e.g. default status_times). Not required for v1.

---

## 4. Algorithm — `OrderItem\CancelledState::handle()`

Inputs available on `$this->item`: `total`, `vat`, `commission`, `unit_price * quantity` (≈ sub_total contribution), `offer_discount + coupon_discount` (item discount contribution).

Pseudocode:

```php
$item       = $this->item;
$order      = $item->order;            // sub-order
$group      = $order->orderGroup;
$payment    = $group->payment;
$client     = $group->client;

// 1. Compute the item's money breakdown
$itemSubtotal   = $item->unit_price * $item->quantity;       // gross before vat/commission
$itemDiscount   = $item->offer_discount + $item->coupon_discount;
$itemVat        = $item->vat;
$itemCommission = $item->commission;
$itemTotal      = $item->total;
// vendor_payout share for the item: subtotal - commission (mirror sub-order math)
$itemVendorPayout = $itemSubtotal - $itemCommission;

// 2. Decrement parent sub-order totals
$order->update([
    'sub_total'     => max(0, $order->sub_total     - $itemSubtotal),
    'discount'      => max(0, $order->discount      - $itemDiscount),
    'vat'           => max(0, $order->vat           - $itemVat),
    'commission'    => max(0, $order->commission    - $itemCommission),
    'vendor_payout' => max(0, $order->vendor_payout - $itemVendorPayout),
    'total'         => max(0, $order->total         - $itemTotal),
]);

// 3. Decrement Payment on the OrderGroup, bump refund_amount
if ($payment) {
    $payment->update([
        'subtotal'          => max(0, $payment->subtotal          - $itemSubtotal),
        'discount'          => max(0, $payment->discount          - $itemDiscount),
        'vat_amount'        => max(0, $payment->vat_amount        - $itemVat),
        'commission_amount' => max(0, $payment->commission_amount - $itemCommission),
        'vendor_gross'      => max(0, $payment->vendor_gross      - $itemSubtotal),
        'vendor_payout'     => max(0, $payment->vendor_payout     - $itemVendorPayout),
        'total'             => max(0, $payment->total             - $itemTotal),
        'refund_amount'     => $payment->refund_amount + $itemTotal,
    ]);
}

// 4. Refund to client wallet
WalletService::make($client)->deposit(
    amount:    $itemTotal,
    type:      WalletTransactionType::REFUND,
    status:    'completed',
    reference: null,
    modelable: $item,
);

// 5. Return inventory for THIS item only
$model = $item->variant ?? $item->product;
if ($model) {
    $sales = $model->inventoryMovements()
        ->where('type', InventoryType::SALE)
        ->where('order_item_id', $item->id)
        ->get();

    foreach ($sales as $sale) {
        $model->inventoryMovements()->create([
            'inventory_id'  => $sale->inventory_id,
            'order_item_id' => $item->id,
            'type'          => InventoryType::RETURN,
            'quantity'      => $sale->quantity,
        ]);
    }
}

// 6. Notifications
notifySafely(supervisors(), new GeneralNotification(NotificationMessages::adminCancelOrderItem($item)));
notifySafely($order->vendor, new GeneralNotification(NotificationMessages::vendorCancelOrderItem($item)));

// 7. Cascade — if no PENDING items remain on the sub-order, cancel the sub-order
$remainingActive = $order->items()
    ->where('status', '!=', OrderStatus::CANCELLED)
    ->count();

if ($remainingActive === 0) {
    // sub-order CancelledState will run; because totals are already 0
    // the Payment update + wallet deposit there become no-ops. Good.
    $order->cancel($item->cancel_reason);
} else {
    notifySafely($client, new GeneralNotification(NotificationMessages::clientCancelOrderItem($item)));
}
```

### Why this avoids double work

| Scenario                                  | Item-level effect                | SubOrder CancelledState effect                     |
| ----------------------------------------- | -------------------------------- | -------------------------------------------------- |
| Cancel 1 of 3 items                       | refund 1 item, return 1 inv.     | not triggered                                      |
| Cancel last remaining item                | refund last item, return inv.    | runs, but `order->total = 0` and items skipped → no-op |
| Cancel whole sub-order (existing flow)    | not triggered                    | refund full order, return inv. for non-cancelled items |
| Cancel whole order group (existing flow)  | not triggered                    | sibling cascade as today                           |

---

## 5. Permissions / guards

- `OrderGroup::can_cancel` already blocks coupon + deferred. Reuse it via the request.
- Item-level cancel only allowed while item.status = PENDING **and** sub-order.status = PENDING.
- Vendor-side and admin-side cancel-item endpoints can be added later under the same state class (just different `cancel_by`).

---

## 6. Test checklist (manual + feature tests)

1. Cancel one of two items → sub-order total drops by item.total, Payment refund_amount rises by item.total, wallet credited, inventory returned only for that item.
2. Cancel last remaining item → sub-order becomes CANCELLED, no extra wallet credit, no extra inventory return.
3. Cancel last item of last sub-order → OrderGroup becomes CANCELLED via existing cascade.
4. Try to cancel after vendor moved to PROCESSING → 400.
5. Try to cancel item from a group with a coupon → 400 (`can_cancel` gate).
6. Try to cancel an item already cancelled → 400.
7. Verify Payment numbers never go negative (clamped with `max(0, …)`).

---

## 7. Order of implementation

1. Migration + `OrderItem` model casts/state hooks.
2. `OrderItem` state classes (Pending, Cancelled).
3. Patch `SubOrder\CancelledState` to skip cancelled items.
4. Request + controller method + route.
5. Notification messages + resource exposure.
6. Feature tests (scenarios 1–7).
