# ECStores Refund Management — Design Spec

## Goal

Give Pro-tier merchants a way to issue and track refunds for orders — both Stripe-processed and manual (e-transfer, cash, store credit) — with a cross-order refund ledger for accounting and reporting.

## Context

- **Platform:** Laravel 13, Livewire 4.3, Filament 5.6, stancl/tenancy 3.x
- **Plan gate:** `has_refunds` on the `Plan` model; `PlanService::hasRefunds()` already wired
- **Tenancy:** All new tables are tenant-scoped (migrations under `database/migrations/tenant/`)
- **Existing pattern:** Follows the same nav-badge + persistent-notification gate pattern as Coupons and Expenses
- **Existing inline refund action:** `OrdersTable` already has a Stripe-only refund action with no plan gate — this design replaces it

---

## Data Layer

### New Table: `refunds`

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `order_id` | FK → orders | cascadeOnDelete |
| `amount` | decimal(10,2) | Refund amount |
| `type` | enum: `stripe`, `manual` | |
| `stripe_refund_id` | varchar(100) | Nullable — the `re_xxx` Stripe ID |
| `reason_code` | enum: `defective`, `wrong_item`, `customer_request`, `duplicate_order`, `other` | |
| `reason_notes` | text | Nullable free-text |
| `issued_at` | date | When the refund was issued |
| `timestamps` | | |

`order_id` uses `cascadeOnDelete` — refund records are part of the order's history and are deleted with it.

### Modify `orders` Table

Add `partially_refunded` to the `status` enum:

```
pending | processing | shipped | cancelled | partially_refunded | refunded
```

The `amount_refunded` column (decimal(10,2), default 0) already exists and is not changed.

### `order_payments` Table

No changes. Stripe refund entries continue to be written there as before (as part of the Stripe ledger). Manual refunds do not write to `order_payments`.

### Status Logic

Applied on every refund submission:

- `amount_refunded += refund amount`
- If `amount_refunded >= order total` → status = `refunded`
- If `0 < amount_refunded < total` → status = `partially_refunded`

---

## Models

### `Refund`

```
$fillable: order_id, amount, type, stripe_refund_id, reason_code, reason_notes, issued_at
$casts: amount → decimal:2, issued_at → date
belongsTo: order() → Order
```

### `Order` (update only)

Add:
```
hasMany: refunds() → Refund
```

No other changes. `amount_refunded` and status are updated imperatively on each refund submission.

---

## Refund Action (on Orders)

Replaces the existing Stripe-only inline refund action in `app/Filament/Resources/Orders/Tables/OrdersTable.php` and the Order edit page header (`app/Filament/Resources/Orders/Pages/EditOrder.php`).

**Visibility:** Hidden when `!hasRefunds()` OR when `amount_refunded >= total` (already fully refunded).

**Form fields:**

| Field | Details |
|---|---|
| `type` | Select: Stripe, Manual — required |
| `amount` | Numeric, prefix `$`, min $0.01, max = `total − amount_refunded`, required |
| `reason_code` | Select: Defective Product, Wrong Item Shipped, Customer Request, Duplicate Order, Other — required |
| `reason_notes` | Textarea, optional |

**On submit:**

1. Create a `Refund` record with all form data + `issued_at = today`
2. If **Stripe**: call `stripe->refunds->create(['payment_intent' => $order->stripe_payment_intent_id, 'amount' => cents])`, store returned `id` as `stripe_refund_id`, write an `order_payments` row (`stripe_object = 'refund'`, `stripe_id = stripe_refund_id`, `amount`, `status = 'succeeded'`)
3. Increment `order->amount_refunded` by the refund amount
4. Update `order->status`: `refunded` if fully refunded, `partially_refunded` if partial
5. Send a success notification to the admin

**If Stripe API fails:** Do not write the `Refund` record. Show a danger notification with the Stripe error message.

---

## Filament Resource

### `RefundResource`

**Location:** `app/Filament/Resources/Refunds/RefundResource.php`

**Navigation:**
- Group: `Orders`
- Sort: after OrderResource (e.g., sort 2)
- Icon: `Heroicon::OutlinedArrowUturnLeft`

**Plan gate:** same pattern as Expenses —
- `getNavigationBadge()` returns `'Pro'` when `!hasRefunds()`, null otherwise
- `getNavigationBadgeColor()` returns `'warning'` when `!hasRefunds()`, null otherwise
- `canCreate()` returns false always (refunds are issued from Orders, not created here)
- `ListRefunds::mount()` sends persistent warning notification when locked

**Table columns:**

| Column | Notes |
|---|---|
| `issued_at` | Date, sortable, default sort desc |
| `order_id` | Label "Order #", linkable to Order edit page |
| `amount` | Prefix `$`, numeric 2dp |
| `type` | Badge — `stripe` → blue (`primary`), `manual` → gray (`secondary`) |
| `reason_code` | Human-readable enum label |
| `reason_notes` | Truncated to 40 chars |

**Filters:**
- Date range (from/to) on `issued_at`
- Select filter on `type`
- Select filter on `reason_code`

**Actions:** `toolbarActions([])` — no create button. Row actions: none (read-only ledger).

No global search — the ledger has no natural title attribute.

---

## Plan Gate Coverage

| Surface | Gate |
|---|---|
| Refund action on OrdersTable | `hidden()` when `!hasRefunds()` |
| Refund action on Order edit page | `hidden()` when `!hasRefunds()` |
| RefundResource nav badge | `'Pro'` warning badge |
| RefundResource `canCreate()` | Always false (creation is disabled by design — refunds come from Orders) |
| ListRefunds `mount()` | Persistent warning notification |

---

## Deployment

1. Run tenant migration (adds `refunds` table, adds `partially_refunded` to `orders.status` enum)
2. Clear caches
3. Verify: Refund action appears on orders for Pro tenants; hidden for lower tiers

---

## Future Considerations (out of scope)

- Refund notifications to customers (email)
- Refund reporting integrated into Financial Reports net-profit calculation
- Bulk refund for cancelled orders
- Automatic refund on order cancellation
