# Layer 4: Cart & Checkout 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 DB-backed cart, Stripe checkout with Redis locking, and order management to the Alqove marketplace.

**Architecture:** Service-orchestrated checkout. CartService handles cart CRUD with live pricing. CheckoutService orchestrates validation, Redis locking (Lua script), Stripe PaymentIntent creation, and webhook-driven fulfillment. All money in cents. Single PaymentIntent with per-store Transfers via Stripe Express Connect.

**Tech Stack:** Laravel 11, Stripe PHP SDK, Redis (Lua scripts), Next.js 15, Stripe.js + React Stripe Elements, Zustand, TanStack Query.

**Spec:** `docs/superpowers/specs/2026-04-14-layer-4-cart-checkout-design.md`

---

## Task 1: Install Stripe PHP SDK and Configure

**Files:**
- Modify: `api/composer.json`
- Modify: `api/config/services.php`
- Modify: `api/.env.example`

- [ ] **Step 1: Install stripe/stripe-php**

Run:
```bash
cd Alqove/api && docker compose exec laravel.test composer require stripe/stripe-php
```

- [ ] **Step 2: Add Stripe config to services.php**

In `api/config/services.php`, add after the `'apple'` block:

```php
'stripe' => [
    'secret' => env('STRIPE_SECRET_KEY'),
    'public' => env('STRIPE_PUBLIC_KEY'),
    'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
```

- [ ] **Step 3: Add Stripe env vars to .env.example**

Append to `api/.env.example`:

```
STRIPE_SECRET_KEY=
STRIPE_PUBLIC_KEY=
STRIPE_WEBHOOK_SECRET=
```

- [ ] **Step 4: Commit**

```bash
git add api/composer.json api/composer.lock api/config/services.php api/.env.example
git commit -m "chore: install stripe/stripe-php and add config"
```

---

## Task 2: OpenAPI Spec — Cart Endpoints

**Files:**
- Modify: `api/contracts/openapi.yaml`

- [ ] **Step 1: Add cart paths and schemas to openapi.yaml**

Add the following paths to the `paths:` section:

```yaml
  /v1/cart:
    get:
      operationId: getCart
      summary: Get current user's cart
      description: Returns cart items grouped by store with live prices. Prices are always fetched fresh from the items table.
      tags:
        - Cart
      responses:
        '200':
          description: Cart contents
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CartResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /v1/cart/items:
    post:
      operationId: addCartItem
      summary: Add item to cart
      tags:
        - Cart
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - item_id
              properties:
                item_id:
                  type: string
                  format: uuid
      responses:
        '201':
          description: Item added to cart
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CartItemResponse'
        '409':
          description: Item unavailable or already sold
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /v1/cart/items/{item_id}:
    delete:
      operationId: removeCartItem
      summary: Remove item from cart
      tags:
        - Cart
      parameters:
        - name: item_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Item removed
        '401':
          $ref: '#/components/responses/Unauthenticated'
```

Add these schemas to `components.schemas`:

```yaml
    CartResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            stores:
              type: array
              items:
                $ref: '#/components/schemas/CartStoreGroup'
            item_count:
              type: integer
            subtotal:
              type: integer
            shipping_total:
              type: integer
            total:
              type: integer

    CartStoreGroup:
      type: object
      properties:
        store:
          type: object
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
            logo_image:
              type: string
              nullable: true
        items:
          type: array
          items:
            $ref: '#/components/schemas/CartItemData'
        subtotal:
          type: integer
        shipping:
          type: integer
        free_shipping_threshold:
          type: integer
          nullable: true

    CartItemData:
      type: object
      properties:
        id:
          type: string
          format: uuid
        item:
          type: object
          properties:
            id:
              type: string
              format: uuid
            title:
              type: string
            price:
              type: integer
            image_url:
              type: string
              nullable: true
            condition:
              type: string
            is_available:
              type: boolean
        added_at:
          type: string
          format: date-time

    CartItemResponse:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/CartItemData'
```

- [ ] **Step 2: Regenerate types**

Run:
```bash
cd Alqove && npm run build:types
```

- [ ] **Step 3: Commit**

```bash
git add api/contracts/openapi.yaml packages/types/
git commit -m "spec: add cart endpoints to OpenAPI"
```

---

## Task 3: OpenAPI Spec — Checkout, Orders, Stripe Connect Endpoints

**Files:**
- Modify: `api/contracts/openapi.yaml`

- [ ] **Step 1: Add checkout paths to openapi.yaml**

```yaml
  /v1/checkout:
    post:
      operationId: initiateCheckout
      summary: Initiate checkout
      description: Validates cart, acquires Redis locks, calculates totals, creates Stripe PaymentIntent.
      tags:
        - Checkout
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CheckoutRequest'
      responses:
        '200':
          description: Checkout initiated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutResponse'
        '409':
          description: Items unavailable or locked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutConflictResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '422':
          $ref: '#/components/responses/ValidationError'

  /v1/checkout/{checkout_id}:
    delete:
      operationId: cancelCheckout
      summary: Cancel checkout and release locks
      tags:
        - Checkout
      parameters:
        - name: checkout_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Checkout cancelled
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /v1/stripe/webhook:
    post:
      operationId: stripeWebhook
      summary: Handle Stripe webhook events
      description: Authenticated via Stripe signature, not bearer token.
      tags:
        - Checkout
      security: []
      responses:
        '200':
          description: Webhook processed

  /v1/purchases:
    get:
      operationId: listPurchases
      summary: List buyer's purchases
      tags:
        - Orders
      parameters:
        - name: page
          in: query
          schema:
            type: integer
        - name: per_page
          in: query
          schema:
            type: integer
      responses:
        '200':
          description: Purchase list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PurchaseListResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /v1/purchases/{purchase_id}:
    get:
      operationId: getPurchase
      summary: Get purchase detail with orders
      tags:
        - Orders
      parameters:
        - name: purchase_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Purchase detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PurchaseDetailResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/stores/{store}/orders:
    get:
      operationId: listStoreOrders
      summary: List orders for a store (seller)
      tags:
        - Orders
      parameters:
        - name: store
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: page
          in: query
          schema:
            type: integer
        - name: per_page
          in: query
          schema:
            type: integer
      responses:
        '200':
          description: Order list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderListResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'

  /v1/stores/{store}/orders/{order}:
    get:
      operationId: getStoreOrder
      summary: Get order detail (seller)
      tags:
        - Orders
      parameters:
        - name: store
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: order
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Order detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderDetailResponse'
        '401':
          $ref: '#/components/responses/Unauthenticated'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/stores/{store}/stripe/connect:
    post:
      operationId: createStripeConnectLink
      summary: Create Stripe Express Connect onboarding link
      tags:
        - Stores
      parameters:
        - name: store
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Onboarding URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      onboarding_url:
                        type: string
                        format: uri
        '401':
          $ref: '#/components/responses/Unauthenticated'
```

- [ ] **Step 2: Add checkout and order schemas**

```yaml
    CheckoutRequest:
      type: object
      required:
        - shipping_address
      properties:
        shipping_address:
          $ref: '#/components/schemas/ShippingAddress'
        excluded_item_ids:
          type: array
          items:
            type: string
            format: uuid

    ShippingAddress:
      type: object
      required:
        - first_name
        - last_name
        - street
        - city
        - state
        - zip
      properties:
        first_name:
          type: string
        last_name:
          type: string
        street:
          type: string
        city:
          type: string
        state:
          type: string
          maxLength: 2
        zip:
          type: string

    CheckoutResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            checkout_id:
              type: string
              format: uuid
            client_secret:
              type: string
            stores:
              type: array
              items:
                $ref: '#/components/schemas/CheckoutStoreGroup'
            subtotal:
              type: integer
            discount_total:
              type: integer
            shipping_total:
              type: integer
            tax_total:
              type: integer
            total:
              type: integer

    CheckoutStoreGroup:
      type: object
      properties:
        store:
          type: object
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
        items:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              title:
                type: string
              price:
                type: integer
        subtotal:
          type: integer
        shipping:
          type: integer

    CheckoutConflictResponse:
      type: object
      properties:
        error:
          type: string
        unavailable_items:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              title:
                type: string
              reason:
                type: string
        available_items:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                format: uuid
              title:
                type: string
              price:
                type: integer
        updated_totals:
          type: object
          properties:
            subtotal:
              type: integer
            shipping_total:
              type: integer
            total:
              type: integer

    PurchaseListResponse:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/PurchaseSummary'
        meta:
          $ref: '#/components/schemas/PaginationMeta'

    PurchaseSummary:
      type: object
      properties:
        id:
          type: string
          format: uuid
        total:
          type: integer
        status:
          type: string
        order_count:
          type: integer
        item_count:
          type: integer
        created_at:
          type: string
          format: date-time

    PurchaseDetailResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            id:
              type: string
              format: uuid
            subtotal:
              type: integer
            discount_total:
              type: integer
            shipping_total:
              type: integer
            tax_total:
              type: integer
            total:
              type: integer
            status:
              type: string
            shipping_address:
              $ref: '#/components/schemas/ShippingAddress'
            orders:
              type: array
              items:
                $ref: '#/components/schemas/OrderDetail'
            created_at:
              type: string
              format: date-time

    OrderDetail:
      type: object
      properties:
        id:
          type: string
          format: uuid
        store:
          type: object
          properties:
            id:
              type: string
              format: uuid
            name:
              type: string
        subtotal:
          type: integer
        shipping_cost:
          type: integer
        status:
          type: string
        items:
          type: array
          items:
            $ref: '#/components/schemas/OrderItemData'
        created_at:
          type: string
          format: date-time

    OrderItemData:
      type: object
      properties:
        id:
          type: string
          format: uuid
        item_id:
          type: string
          format: uuid
        title_snapshot:
          type: string
        price_snapshot:
          type: integer
        image_url_snapshot:
          type: string
          nullable: true

    OrderListResponse:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/OrderDetail'
        meta:
          $ref: '#/components/schemas/PaginationMeta'

    OrderDetailResponse:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/OrderDetail'
```

- [ ] **Step 3: Regenerate types**

Run:
```bash
cd Alqove && npm run build:types
```

- [ ] **Step 4: Commit**

```bash
git add api/contracts/openapi.yaml packages/types/
git commit -m "spec: add checkout, orders, and Stripe Connect endpoints to OpenAPI"
```

---

## Task 4: CartService — Add/Remove/Get with Live Pricing

**Files:**
- Create: `api/app/Modules/Cart/Services/CartService.php`
- Create: `api/app/Modules/Cart/Resources/CartResource.php`
- Create: `api/app/Modules/Cart/Resources/CartItemResource.php`

- [ ] **Step 1: Write CartService tests**

Create `api/tests/Feature/Cart/CartTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Cart;

use App\Models\Cart;
use App\Models\CartItem;
use App\Models\Category;
use App\Models\Item;
use App\Models\Store;
use App\Models\StoreSettings;
use App\Models\User;
use App\Support\Enums\ItemStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class CartTest extends TestCase
{
    use RefreshDatabase;

    private User $buyer;
    private Store $store;
    private Category $category;

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

        $this->store = Store::factory()->verified()->create();
        StoreSettings::factory()->create([
            'store_id' => $this->store->id,
            'flat_shipping_rate' => 1200,
            'free_shipping_threshold' => 35000,
        ]);
        $this->category = Category::factory()->create();
        $this->buyer = User::factory()->create();
        $this->buyer->assignRole('buyer');
    }

    public function test_buyer_can_add_item_to_cart(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 5000,
        ]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/cart/items', ['item_id' => $item->id]);

        $response->assertStatus(201)
            ->assertJsonPath('data.item.id', $item->id)
            ->assertJsonPath('data.item.price', 5000);

        $this->assertDatabaseHas('cart_items', [
            'item_id' => $item->id,
        ]);
    }

    public function test_adding_same_item_twice_is_idempotent(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
        ]);

        $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/cart/items', ['item_id' => $item->id])
            ->assertStatus(201);

        $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/cart/items', ['item_id' => $item->id])
            ->assertStatus(200);

        $this->assertDatabaseCount('cart_items', 1);
    }

    public function test_cannot_add_sold_item_to_cart(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Sold,
        ]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/cart/items', ['item_id' => $item->id]);

        $response->assertStatus(409);
    }

    public function test_cannot_add_nonexistent_item(): void
    {
        $response = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/cart/items', ['item_id' => '00000000-0000-0000-0000-000000000000']);

        $response->assertStatus(422);
    }

    public function test_buyer_can_remove_item_from_cart(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create([
            'cart_id' => $cart->id,
            'item_id' => $item->id,
            'added_at' => now(),
        ]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->deleteJson("/v1/cart/items/{$item->id}");

        $response->assertStatus(204);
        $this->assertDatabaseMissing('cart_items', ['item_id' => $item->id]);
    }

    public function test_get_cart_returns_items_grouped_by_store(): void
    {
        $store2 = Store::factory()->verified()->create();
        StoreSettings::factory()->create([
            'store_id' => $store2->id,
            'flat_shipping_rate' => 850,
            'free_shipping_threshold' => null,
        ]);

        $item1 = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 24500,
        ]);
        $item2 = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 6800,
        ]);
        $item3 = Item::factory()->create([
            'store_id' => $store2->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 8500,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item1->id, 'added_at' => now()]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item2->id, 'added_at' => now()]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item3->id, 'added_at' => now()]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->getJson('/v1/cart');

        $response->assertStatus(200)
            ->assertJsonCount(2, 'data.stores')
            ->assertJsonPath('data.item_count', 3)
            ->assertJsonPath('data.subtotal', 39800);
    }

    public function test_get_cart_uses_live_prices(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 5000,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item->id, 'added_at' => now()]);

        // Price changes after item is in cart
        $item->update(['price' => 4000]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->getJson('/v1/cart');

        $response->assertStatus(200)
            ->assertJsonPath('data.stores.0.items.0.item.price', 4000)
            ->assertJsonPath('data.subtotal', 4000);
    }

    public function test_empty_cart_returns_empty_stores(): void
    {
        $response = $this->actingAs($this->buyer, 'sanctum')
            ->getJson('/v1/cart');

        $response->assertStatus(200)
            ->assertJsonPath('data.stores', [])
            ->assertJsonPath('data.item_count', 0)
            ->assertJsonPath('data.total', 0);
    }

    public function test_free_shipping_threshold_applied(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 40000,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item->id, 'added_at' => now()]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->getJson('/v1/cart');

        // Store has free_shipping_threshold of 35000, item is 40000 => free shipping
        $response->assertStatus(200)
            ->assertJsonPath('data.stores.0.shipping', 0)
            ->assertJsonPath('data.shipping_total', 0);
    }

    public function test_unauthenticated_user_cannot_access_cart(): void
    {
        $this->getJson('/v1/cart')->assertStatus(401);
        $this->postJson('/v1/cart/items', ['item_id' => 'x'])->assertStatus(401);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=CartTest
```
Expected: All tests fail (routes/controllers don't exist yet).

- [ ] **Step 3: Create CartService**

Create `api/app/Modules/Cart/Services/CartService.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Cart\Services;

use App\Models\Cart;
use App\Models\CartItem;
use App\Models\Item;
use App\Models\User;
use App\Support\Enums\ItemStatus;

class CartService
{
    public function getCart(User $user): array
    {
        $cart = Cart::where('user_id', $user->id)->first();

        if (! $cart) {
            return [
                'stores' => [],
                'item_count' => 0,
                'subtotal' => 0,
                'shipping_total' => 0,
                'total' => 0,
            ];
        }

        $cartItems = $cart->cartItems()
            ->with(['item.store.settings', 'item.media'])
            ->get();

        $grouped = $cartItems->groupBy(fn (CartItem $ci) => $ci->item->store_id);

        $stores = [];
        $subtotal = 0;
        $shippingTotal = 0;

        foreach ($grouped as $storeId => $storeCartItems) {
            $store = $storeCartItems->first()->item->store;
            $settings = $store->settings;

            $storeSubtotal = $storeCartItems->sum(fn (CartItem $ci) => $ci->item->price);
            $subtotal += $storeSubtotal;

            $shipping = $settings?->flat_shipping_rate ?? 0;
            if ($settings?->free_shipping_threshold && $storeSubtotal >= $settings->free_shipping_threshold) {
                $shipping = 0;
            }
            $shippingTotal += $shipping;

            $stores[] = [
                'store' => [
                    'id' => $store->id,
                    'name' => $store->name,
                    'logo_image' => $store->logo_image,
                ],
                'items' => $storeCartItems->map(fn (CartItem $ci) => [
                    'id' => $ci->id,
                    'item' => [
                        'id' => $ci->item->id,
                        'title' => $ci->item->title,
                        'price' => $ci->item->price,
                        'image_url' => $ci->item->getCoverImageUrl(),
                        'condition' => $ci->item->condition->value,
                        'is_available' => $ci->item->status === ItemStatus::Active,
                    ],
                    'added_at' => $ci->added_at?->toIso8601String(),
                ])->values()->all(),
                'subtotal' => $storeSubtotal,
                'shipping' => $shipping,
                'free_shipping_threshold' => $settings?->free_shipping_threshold,
            ];
        }

        return [
            'stores' => $stores,
            'item_count' => $cartItems->count(),
            'subtotal' => $subtotal,
            'shipping_total' => $shippingTotal,
            'total' => $subtotal + $shippingTotal,
        ];
    }

    public function addItem(User $user, string $itemId): CartItem
    {
        $item = Item::findOrFail($itemId);

        if ($item->status !== ItemStatus::Active) {
            abort(409, 'Item is not available for purchase.');
        }

        $cart = Cart::firstOrCreate(['user_id' => $user->id]);

        $existing = CartItem::where('cart_id', $cart->id)
            ->where('item_id', $itemId)
            ->first();

        if ($existing) {
            return $existing;
        }

        return CartItem::create([
            'cart_id' => $cart->id,
            'item_id' => $itemId,
            'added_at' => now(),
        ]);
    }

    public function removeItem(User $user, string $itemId): void
    {
        $cart = Cart::where('user_id', $user->id)->first();

        if (! $cart) {
            return;
        }

        CartItem::where('cart_id', $cart->id)
            ->where('item_id', $itemId)
            ->delete();
    }

    public function clearCart(User $user): void
    {
        $cart = Cart::where('user_id', $user->id)->first();

        if ($cart) {
            $cart->cartItems()->delete();
        }
    }
}
```

- [ ] **Step 4: Create CartController**

Create `api/app/Modules/Cart/Controllers/CartController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Cart\Controllers;

use App\Modules\Cart\Requests\AddCartItemRequest;
use App\Modules\Cart\Services\CartService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CartController
{
    public function __construct(
        private readonly CartService $cartService,
    ) {}

    public function index(Request $request): JsonResponse
    {
        $cart = $this->cartService->getCart($request->user());

        return response()->json(['data' => $cart]);
    }

    public function store(AddCartItemRequest $request): JsonResponse
    {
        $cartItem = $this->cartService->addItem(
            $request->user(),
            $request->validated('item_id'),
        );

        $cartItem->load('item.media');
        $isNew = $cartItem->wasRecentlyCreated;

        return response()->json([
            'data' => [
                'id' => $cartItem->id,
                'item' => [
                    'id' => $cartItem->item->id,
                    'title' => $cartItem->item->title,
                    'price' => $cartItem->item->price,
                    'image_url' => $cartItem->item->getCoverImageUrl(),
                    'condition' => $cartItem->item->condition->value,
                    'is_available' => true,
                ],
                'added_at' => $cartItem->added_at?->toIso8601String(),
            ],
        ], $isNew ? 201 : 200);
    }

    public function destroy(Request $request, string $itemId): JsonResponse
    {
        $this->cartService->removeItem($request->user(), $itemId);

        return response()->json(null, 204);
    }
}
```

- [ ] **Step 5: Create AddCartItemRequest**

Create `api/app/Modules/Cart/Requests/AddCartItemRequest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Cart\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    public function rules(): array
    {
        return [
            'item_id' => ['required', 'uuid', 'exists:items,id'],
        ];
    }
}
```

- [ ] **Step 6: Create Cart routes**

Replace contents of `api/app/Modules/Cart/routes.php`:

```php
<?php

declare(strict_types=1);

use App\Modules\Cart\Controllers\CartController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/cart', [CartController::class, 'index']);
    Route::post('/cart/items', [CartController::class, 'store']);
    Route::delete('/cart/items/{itemId}', [CartController::class, 'destroy']);
});
```

- [ ] **Step 7: Register Cart routes in api.php**

In `api/routes/api.php`, add after the Categories require:

```php
    require app_path('Modules/Cart/routes.php');
```

- [ ] **Step 8: Run tests**

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=CartTest
```
Expected: All tests pass.

- [ ] **Step 9: Commit**

```bash
git add api/app/Modules/Cart/ api/routes/api.php api/tests/Feature/Cart/
git commit -m "feat(cart): add CartService, controller, and routes with tests"
```

---

## Task 5: CheckoutLockService — Redis Lua Script

**Files:**
- Create: `api/app/Modules/Checkout/Services/CheckoutLockService.php`
- Create: `api/tests/Feature/Checkout/CheckoutLockTest.php`

- [ ] **Step 1: Write CheckoutLockService tests**

Create `api/tests/Feature/Checkout/CheckoutLockTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Checkout;

use App\Modules\Checkout\Services\CheckoutLockService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;

class CheckoutLockTest extends TestCase
{
    use RefreshDatabase;

    private CheckoutLockService $lockService;

    protected function setUp(): void
    {
        parent::setUp();
        $this->lockService = app(CheckoutLockService::class);
        Redis::flushdb();
    }

    protected function tearDown(): void
    {
        Redis::flushdb();
        parent::tearDown();
    }

    public function test_acquire_lock_succeeds_for_unlocked_items(): void
    {
        $result = $this->lockService->acquire('checkout-1', ['item-a', 'item-b']);

        $this->assertTrue($result['success']);
        $this->assertEmpty($result['locked_item_ids']);

        $this->assertNotNull(Redis::get('checkout:lock:item:item-a'));
        $this->assertNotNull(Redis::get('checkout:lock:item:item-b'));
    }

    public function test_acquire_lock_fails_when_items_already_locked(): void
    {
        $this->lockService->acquire('checkout-1', ['item-a', 'item-b']);

        $result = $this->lockService->acquire('checkout-2', ['item-b', 'item-c']);

        $this->assertFalse($result['success']);
        $this->assertContains('item-b', $result['locked_item_ids']);

        // item-c should NOT be locked (rollback)
        $this->assertNull(Redis::get('checkout:lock:item:item-c'));
    }

    public function test_release_removes_all_locks(): void
    {
        $this->lockService->acquire('checkout-1', ['item-a', 'item-b']);

        $this->lockService->release('checkout-1', ['item-a', 'item-b']);

        $this->assertNull(Redis::get('checkout:lock:item:item-a'));
        $this->assertNull(Redis::get('checkout:lock:item:item-b'));
    }

    public function test_extend_increases_ttl(): void
    {
        $this->lockService->acquire('checkout-1', ['item-a']);

        $ttlBefore = Redis::ttl('checkout:lock:item:item-a');

        $this->lockService->extend('checkout-1', ['item-a']);

        $ttlAfter = Redis::ttl('checkout:lock:item:item-a');

        $this->assertGreaterThan($ttlBefore, $ttlAfter);
    }

    public function test_lock_ttl_is_set(): void
    {
        $this->lockService->acquire('checkout-1', ['item-a']);

        $ttl = Redis::ttl('checkout:lock:item:item-a');

        $this->assertGreaterThan(500, $ttl);
        $this->assertLessThanOrEqual(600, $ttl);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=CheckoutLockTest
```

- [ ] **Step 3: Create CheckoutLockService**

Create `api/app/Modules/Checkout/Services/CheckoutLockService.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Checkout\Services;

use Illuminate\Support\Facades\Redis;

class CheckoutLockService
{
    private const KEY_PREFIX = 'checkout:lock:item:';
    private const LOCK_TTL = 600;
    private const EXTEND_TTL = 300;

    private const LUA_ACQUIRE = <<<'LUA'
        local checkout_id = ARGV[1]
        local ttl = tonumber(ARGV[2])
        local locked = {}

        for i, key in ipairs(KEYS) do
            local existing = redis.call('GET', key)
            if existing and existing ~= checkout_id then
                -- Conflict: rollback all keys we just set
                for j = 1, i - 1 do
                    local val = redis.call('GET', KEYS[j])
                    if val == checkout_id then
                        redis.call('DEL', KEYS[j])
                    end
                end
                -- Collect all conflicting keys
                table.insert(locked, key)
                for k = i + 1, #KEYS do
                    local val = redis.call('GET', KEYS[k])
                    if val and val ~= checkout_id then
                        table.insert(locked, KEYS[k])
                    end
                end
                return locked
            end
            redis.call('SET', key, checkout_id, 'NX', 'EX', ttl)
        end

        return {}
    LUA;

    /**
     * @param  string[]  $itemIds
     * @return array{success: bool, locked_item_ids: string[]}
     */
    public function acquire(string $checkoutId, array $itemIds): array
    {
        $keys = array_map(fn (string $id) => self::KEY_PREFIX.$id, $itemIds);

        $result = Redis::eval(
            self::LUA_ACQUIRE,
            count($keys),
            ...[...$keys, $checkoutId, (string) self::LOCK_TTL],
        );

        $lockedItemIds = array_map(
            fn (string $key) => str_replace(self::KEY_PREFIX, '', $key),
            $result ?? [],
        );

        return [
            'success' => empty($lockedItemIds),
            'locked_item_ids' => $lockedItemIds,
        ];
    }

    /**
     * @param  string[]  $itemIds
     */
    public function extend(string $checkoutId, array $itemIds): void
    {
        foreach ($itemIds as $itemId) {
            $key = self::KEY_PREFIX.$itemId;
            $current = Redis::get($key);
            if ($current === $checkoutId) {
                Redis::expire($key, self::LOCK_TTL + self::EXTEND_TTL);
            }
        }
    }

    /**
     * @param  string[]  $itemIds
     */
    public function release(string $checkoutId, array $itemIds): void
    {
        foreach ($itemIds as $itemId) {
            $key = self::KEY_PREFIX.$itemId;
            $current = Redis::get($key);
            if ($current === $checkoutId) {
                Redis::del($key);
            }
        }
    }
}
```

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

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=CheckoutLockTest
```
Expected: All tests pass.

- [ ] **Step 5: Commit**

```bash
git add api/app/Modules/Checkout/Services/CheckoutLockService.php api/tests/Feature/Checkout/
git commit -m "feat(checkout): add CheckoutLockService with Redis Lua script"
```

---

## Task 6: StripeService

**Files:**
- Create: `api/app/Modules/Checkout/Services/StripeService.php`

- [ ] **Step 1: Create StripeService**

Create `api/app/Modules/Checkout/Services/StripeService.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Checkout\Services;

use App\Models\Store;
use Stripe\Account;
use Stripe\AccountLink;
use Stripe\PaymentIntent;
use Stripe\Stripe;
use Stripe\Transfer;
use Stripe\Webhook;

class StripeService
{
    public function __construct()
    {
        Stripe::setApiKey(config('services.stripe.secret'));
    }

    public function createConnectAccount(Store $store, string $returnUrl, string $refreshUrl): string
    {
        if ($store->stripe_connect_id) {
            $accountLink = AccountLink::create([
                'account' => $store->stripe_connect_id,
                'refresh_url' => $refreshUrl,
                'return_url' => $returnUrl,
                'type' => 'account_onboarding',
            ]);

            return $accountLink->url;
        }

        $account = Account::create([
            'type' => 'express',
            'metadata' => [
                'store_id' => $store->id,
            ],
        ]);

        $store->update(['stripe_connect_id' => $account->id]);

        $accountLink = AccountLink::create([
            'account' => $account->id,
            'refresh_url' => $refreshUrl,
            'return_url' => $returnUrl,
            'type' => 'account_onboarding',
        ]);

        return $accountLink->url;
    }

    public function createPaymentIntent(int $amount, string $transferGroup, array $metadata): PaymentIntent
    {
        return PaymentIntent::create([
            'amount' => $amount,
            'currency' => 'usd',
            'transfer_group' => $transferGroup,
            'metadata' => $metadata,
        ]);
    }

    public function cancelPaymentIntent(string $paymentIntentId): void
    {
        $intent = PaymentIntent::retrieve($paymentIntentId);
        $intent->cancel();
    }

    public function createTransfer(int $amount, string $destination, string $transferGroup): Transfer
    {
        return Transfer::create([
            'amount' => $amount,
            'currency' => 'usd',
            'destination' => $destination,
            'transfer_group' => $transferGroup,
        ]);
    }

    public function verifyWebhookSignature(string $payload, string $signature): \Stripe\Event
    {
        return Webhook::constructEvent(
            $payload,
            $signature,
            config('services.stripe.webhook_secret'),
        );
    }

    public function isAccountActive(string $accountId): bool
    {
        $account = Account::retrieve($accountId);

        return $account->charges_enabled;
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add api/app/Modules/Checkout/Services/StripeService.php
git commit -m "feat(checkout): add StripeService wrapping Stripe SDK"
```

---

## Task 7: DiscountService Stub

**Files:**
- Create: `api/app/Modules/Checkout/Services/DiscountService.php`

- [ ] **Step 1: Create DiscountService stub**

Create `api/app/Modules/Checkout/Services/DiscountService.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Checkout\Services;

use App\Models\Discount;

class DiscountService
{
    /**
     * @return array{amount: int, discount: Discount|null}
     */
    public function calculate(array $cartData, ?string $code = null): array
    {
        return [
            'amount' => 0,
            'discount' => null,
        ];
    }

    public function validate(?string $code): ?Discount
    {
        return null;
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add api/app/Modules/Checkout/Services/DiscountService.php
git commit -m "feat(checkout): add DiscountService stub returning zero"
```

---

## Task 8: CheckoutService — Initiate & Cancel

**Files:**
- Create: `api/app/Modules/Checkout/Services/CheckoutService.php`
- Create: `api/tests/Feature/Checkout/CheckoutTest.php`

- [ ] **Step 1: Write CheckoutService tests**

Create `api/tests/Feature/Checkout/CheckoutTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Checkout;

use App\Models\Cart;
use App\Models\CartItem;
use App\Models\Category;
use App\Models\Item;
use App\Models\Store;
use App\Models\StoreSettings;
use App\Models\User;
use App\Modules\Checkout\Services\StripeService;
use App\Support\Enums\ItemStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Redis;
use Mockery;
use Tests\TestCase;

class CheckoutTest extends TestCase
{
    use RefreshDatabase;

    private User $buyer;
    private Store $store;
    private Category $category;

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

        $this->store = Store::factory()->verified()->create([
            'stripe_connect_id' => 'acct_test123',
        ]);
        StoreSettings::factory()->create([
            'store_id' => $this->store->id,
            'flat_shipping_rate' => 1200,
            'free_shipping_threshold' => 35000,
        ]);
        $this->category = Category::factory()->create();
        $this->buyer = User::factory()->create();
        $this->buyer->assignRole('buyer');

        // Mock Stripe service
        $mockStripe = Mockery::mock(StripeService::class);
        $mockStripe->shouldReceive('isAccountActive')->andReturn(true);
        $mockStripe->shouldReceive('createPaymentIntent')->andReturn(
            new class {
                public string $id = 'pi_test123';
                public string $client_secret = 'pi_test123_secret_test';
            }
        );
        $mockStripe->shouldReceive('cancelPaymentIntent')->andReturn(null);
        $this->app->instance(StripeService::class, $mockStripe);
    }

    protected function tearDown(): void
    {
        Redis::flushdb();
        parent::tearDown();
    }

    public function test_initiate_checkout_success(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 24500,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item->id, 'added_at' => now()]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/checkout', [
                'shipping_address' => [
                    'first_name' => 'Jane',
                    'last_name' => 'Smith',
                    'street' => '123 Main St',
                    'city' => 'Portland',
                    'state' => 'OR',
                    'zip' => '97201',
                ],
            ]);

        $response->assertStatus(200)
            ->assertJsonStructure([
                'data' => ['checkout_id', 'client_secret', 'stores', 'subtotal', 'shipping_total', 'total'],
            ])
            ->assertJsonPath('data.subtotal', 24500)
            ->assertJsonPath('data.discount_total', 0)
            ->assertJsonPath('data.client_secret', 'pi_test123_secret_test');
    }

    public function test_checkout_returns_409_for_unavailable_items(): void
    {
        $available = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 24500,
        ]);
        $sold = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Sold,
            'price' => 6800,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $available->id, 'added_at' => now()]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $sold->id, 'added_at' => now()]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/checkout', [
                'shipping_address' => [
                    'first_name' => 'Jane',
                    'last_name' => 'Smith',
                    'street' => '123 Main St',
                    'city' => 'Portland',
                    'state' => 'OR',
                    'zip' => '97201',
                ],
            ]);

        $response->assertStatus(409)
            ->assertJsonPath('error', 'items_unavailable')
            ->assertJsonCount(1, 'unavailable_items')
            ->assertJsonCount(1, 'available_items');
    }

    public function test_checkout_fails_without_stripe_connect(): void
    {
        $storeNoStripe = Store::factory()->verified()->create([
            'stripe_connect_id' => null,
        ]);
        StoreSettings::factory()->create(['store_id' => $storeNoStripe->id]);

        $item = Item::factory()->create([
            'store_id' => $storeNoStripe->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item->id, 'added_at' => now()]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/checkout', [
                'shipping_address' => [
                    'first_name' => 'Jane',
                    'last_name' => 'Smith',
                    'street' => '123 Main St',
                    'city' => 'Portland',
                    'state' => 'OR',
                    'zip' => '97201',
                ],
            ]);

        $response->assertStatus(422)
            ->assertJsonPath('message', 'Some stores cannot accept payments yet.');
    }

    public function test_cancel_checkout_releases_locks(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 5000,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item->id, 'added_at' => now()]);

        $initResponse = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/checkout', [
                'shipping_address' => [
                    'first_name' => 'Jane',
                    'last_name' => 'Smith',
                    'street' => '123 Main St',
                    'city' => 'Portland',
                    'state' => 'OR',
                    'zip' => '97201',
                ],
            ]);

        $checkoutId = $initResponse->json('data.checkout_id');

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->deleteJson("/v1/checkout/{$checkoutId}");

        $response->assertStatus(204);

        $this->assertNull(Redis::get("checkout:lock:item:{$item->id}"));
    }

    public function test_checkout_validates_shipping_address(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item->id, 'added_at' => now()]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/checkout', [
                'shipping_address' => [],
            ]);

        $response->assertStatus(422)
            ->assertJsonValidationErrors([
                'shipping_address.first_name',
                'shipping_address.last_name',
                'shipping_address.street',
                'shipping_address.city',
                'shipping_address.state',
                'shipping_address.zip',
            ]);
    }

    public function test_checkout_with_empty_cart_fails(): void
    {
        $response = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/checkout', [
                'shipping_address' => [
                    'first_name' => 'Jane',
                    'last_name' => 'Smith',
                    'street' => '123 Main St',
                    'city' => 'Portland',
                    'state' => 'OR',
                    'zip' => '97201',
                ],
            ]);

        $response->assertStatus(422)
            ->assertJsonPath('message', 'Cart is empty.');
    }

    public function test_shipping_calculation_with_free_threshold(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 40000,
        ]);

        $cart = Cart::create(['user_id' => $this->buyer->id]);
        CartItem::create(['cart_id' => $cart->id, 'item_id' => $item->id, 'added_at' => now()]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->postJson('/v1/checkout', [
                'shipping_address' => [
                    'first_name' => 'Jane',
                    'last_name' => 'Smith',
                    'street' => '123 Main St',
                    'city' => 'Portland',
                    'state' => 'OR',
                    'zip' => '97201',
                ],
            ]);

        $response->assertStatus(200)
            ->assertJsonPath('data.shipping_total', 0);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=CheckoutTest
```

- [ ] **Step 3: Create CheckoutService**

Create `api/app/Modules/Checkout/Services/CheckoutService.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Checkout\Services;

use App\Models\Cart;
use App\Models\CartItem;
use App\Models\Item;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Purchase;
use App\Models\User;
use App\Support\Enums\ItemStatus;
use App\Support\Enums\OrderStatus;
use App\Support\Enums\PurchaseStatus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

class CheckoutService
{
    public function __construct(
        private readonly CheckoutLockService $lockService,
        private readonly StripeService $stripeService,
        private readonly DiscountService $discountService,
    ) {}

    public function initiate(User $user, array $shippingAddress, array $excludedItemIds = []): array
    {
        $cart = Cart::where('user_id', $user->id)->first();

        if (! $cart || $cart->cartItems()->count() === 0) {
            abort(422, 'Cart is empty.');
        }

        $cartItems = $cart->cartItems()
            ->with(['item.store.settings'])
            ->get()
            ->reject(fn (CartItem $ci) => in_array($ci->item_id, $excludedItemIds));

        // Check availability
        $available = [];
        $unavailable = [];

        foreach ($cartItems as $cartItem) {
            if ($cartItem->item->status === ItemStatus::Active) {
                $available[] = $cartItem;
            } else {
                $unavailable[] = [
                    'id' => $cartItem->item->id,
                    'title' => $cartItem->item->title,
                    'reason' => $cartItem->item->status === ItemStatus::Sold ? 'sold' : 'unavailable',
                ];
            }
        }

        if (! empty($unavailable)) {
            $availableData = collect($available)->map(fn (CartItem $ci) => [
                'id' => $ci->item->id,
                'title' => $ci->item->title,
                'price' => $ci->item->price,
            ])->values()->all();

            $grouped = collect($available)->groupBy(fn (CartItem $ci) => $ci->item->store_id);
            $subtotal = collect($available)->sum(fn (CartItem $ci) => $ci->item->price);
            $shippingTotal = 0;
            foreach ($grouped as $storeCartItems) {
                $store = $storeCartItems->first()->item->store;
                $settings = $store->settings;
                $storeSubtotal = $storeCartItems->sum(fn (CartItem $ci) => $ci->item->price);
                $shipping = $settings?->flat_shipping_rate ?? 0;
                if ($settings?->free_shipping_threshold && $storeSubtotal >= $settings->free_shipping_threshold) {
                    $shipping = 0;
                }
                $shippingTotal += $shipping;
            }

            return response()->json([
                'error' => 'items_unavailable',
                'unavailable_items' => $unavailable,
                'available_items' => $availableData,
                'updated_totals' => [
                    'subtotal' => $subtotal,
                    'shipping_total' => $shippingTotal,
                    'total' => $subtotal + $shippingTotal,
                ],
            ], 409)->getData(true);
        }

        // Check all stores have Stripe Connect
        $stores = collect($available)->map(fn (CartItem $ci) => $ci->item->store)->unique('id');
        foreach ($stores as $store) {
            if (! $store->stripe_connect_id) {
                abort(422, 'Some stores cannot accept payments yet.');
            }
        }

        // Acquire locks
        $itemIds = collect($available)->pluck('item.id')->all();
        $checkoutId = (string) Str::uuid();

        $lockResult = $this->lockService->acquire($checkoutId, $itemIds);
        if (! $lockResult['success']) {
            return response()->json([
                'error' => 'items_locked',
                'locked_item_ids' => $lockResult['locked_item_ids'],
            ], 409)->getData(true);
        }

        // Calculate totals
        $grouped = collect($available)->groupBy(fn (CartItem $ci) => $ci->item->store_id);
        $subtotal = 0;
        $shippingTotal = 0;
        $storeBreakdowns = [];

        foreach ($grouped as $storeId => $storeCartItems) {
            $store = $storeCartItems->first()->item->store;
            $settings = $store->settings;
            $storeSubtotal = $storeCartItems->sum(fn (CartItem $ci) => $ci->item->price);
            $subtotal += $storeSubtotal;

            $shipping = $settings?->flat_shipping_rate ?? 0;
            if ($settings?->free_shipping_threshold && $storeSubtotal >= $settings->free_shipping_threshold) {
                $shipping = 0;
            }
            $shippingTotal += $shipping;

            $storeBreakdowns[] = [
                'store' => [
                    'id' => $store->id,
                    'name' => $store->name,
                ],
                'items' => $storeCartItems->map(fn (CartItem $ci) => [
                    'id' => $ci->item->id,
                    'title' => $ci->item->title,
                    'price' => $ci->item->price,
                ])->values()->all(),
                'subtotal' => $storeSubtotal,
                'shipping' => $shipping,
            ];
        }

        $discountResult = $this->discountService->calculate([], null);
        $discountTotal = $discountResult['amount'];

        $total = $subtotal + $shippingTotal - $discountTotal;

        // Create PaymentIntent
        $transferGroup = "checkout_{$checkoutId}";
        $paymentIntent = $this->stripeService->createPaymentIntent($total, $transferGroup, [
            'checkout_id' => $checkoutId,
            'user_id' => $user->id,
        ]);

        // Extend locks
        $this->lockService->extend($checkoutId, $itemIds);

        // Store checkout data in cache for webhook fulfillment
        cache()->put("checkout:{$checkoutId}", [
            'user_id' => $user->id,
            'payment_intent_id' => $paymentIntent->id,
            'shipping_address' => $shippingAddress,
            'stores' => $storeBreakdowns,
            'subtotal' => $subtotal,
            'discount_total' => $discountTotal,
            'shipping_total' => $shippingTotal,
            'tax_total' => 0,
            'total' => $total,
            'item_ids' => $itemIds,
        ], now()->addMinutes(30));

        return [
            'checkout_id' => $checkoutId,
            'client_secret' => $paymentIntent->client_secret,
            'stores' => $storeBreakdowns,
            'subtotal' => $subtotal,
            'discount_total' => $discountTotal,
            'shipping_total' => $shippingTotal,
            'tax_total' => 0,
            'total' => $total,
        ];
    }

    public function cancel(User $user, string $checkoutId): void
    {
        $checkoutData = cache()->get("checkout:{$checkoutId}");

        if (! $checkoutData || $checkoutData['user_id'] !== $user->id) {
            abort(404);
        }

        $this->stripeService->cancelPaymentIntent($checkoutData['payment_intent_id']);
        $this->lockService->release($checkoutId, $checkoutData['item_ids']);
        cache()->forget("checkout:{$checkoutId}");
    }

    public function fulfillPayment(string $paymentIntentId): void
    {
        // Idempotency check
        if (Purchase::where('stripe_payment_intent_id', $paymentIntentId)->exists()) {
            return;
        }

        // Find checkout data by payment intent
        // Search cache for matching checkout
        $checkoutData = null;
        $checkoutId = null;

        // PaymentIntent metadata has checkout_id
        $paymentIntent = \Stripe\PaymentIntent::retrieve($paymentIntentId);
        $checkoutId = $paymentIntent->metadata['checkout_id'] ?? null;

        if ($checkoutId) {
            $checkoutData = cache()->get("checkout:{$checkoutId}");
        }

        if (! $checkoutData) {
            throw new \RuntimeException("Checkout data not found for payment intent: {$paymentIntentId}");
        }

        $user = User::findOrFail($checkoutData['user_id']);

        DB::transaction(function () use ($checkoutData, $paymentIntentId, $checkoutId, $user) {
            $purchase = Purchase::create([
                'buyer_id' => $checkoutData['user_id'],
                'stripe_payment_intent_id' => $paymentIntentId,
                'subtotal' => $checkoutData['subtotal'],
                'discount_total' => $checkoutData['discount_total'],
                'shipping_total' => $checkoutData['shipping_total'],
                'tax_total' => $checkoutData['tax_total'],
                'total' => $checkoutData['total'],
                'status' => PurchaseStatus::Paid,
                'shipping_address' => $checkoutData['shipping_address'],
            ]);

            $transferGroup = "checkout_{$checkoutId}";

            foreach ($checkoutData['stores'] as $storeData) {
                $storeSubtotal = $storeData['subtotal'];
                $platformFee = (int) round($storeSubtotal * 0.15);
                $sellerPayout = $storeSubtotal - $platformFee;

                $order = Order::create([
                    'purchase_id' => $purchase->id,
                    'store_id' => $storeData['store']['id'],
                    'subtotal' => $storeSubtotal,
                    'discount_amount' => 0,
                    'shipping_cost' => $storeData['shipping'],
                    'tax_amount' => 0,
                    'platform_fee' => $platformFee,
                    'seller_payout' => $sellerPayout,
                    'status' => OrderStatus::Pending,
                    'ship_by' => now()->addDays(3),
                ]);

                foreach ($storeData['items'] as $itemData) {
                    $item = Item::find($itemData['id']);

                    OrderItem::create([
                        'order_id' => $order->id,
                        'item_id' => $itemData['id'],
                        'price_snapshot' => $itemData['price'],
                        'title_snapshot' => $itemData['title'],
                        'image_url_snapshot' => $item?->getCoverImageUrl(),
                    ]);

                    // Mark item as sold
                    if ($item) {
                        $item->update([
                            'status' => ItemStatus::Sold,
                            'sold_at' => now(),
                            'sold_to_user_id' => $checkoutData['user_id'],
                        ]);
                    }
                }

                // Create Stripe transfer to store
                $store = \App\Models\Store::find($storeData['store']['id']);
                if ($store?->stripe_connect_id && $sellerPayout > 0) {
                    $transfer = $this->stripeService->createTransfer(
                        $sellerPayout,
                        $store->stripe_connect_id,
                        $transferGroup,
                    );
                    $order->update(['stripe_transfer_id' => $transfer->id]);
                }
            }

            // Release locks and clear cart
            $this->lockService->release($checkoutId, $checkoutData['item_ids']);
            (new \App\Modules\Cart\Services\CartService)->clearCart($user);
            cache()->forget("checkout:{$checkoutId}");
        });
    }
}
```

- [ ] **Step 4: Create CheckoutController**

Create `api/app/Modules/Checkout/Controllers/CheckoutController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Checkout\Controllers;

use App\Modules\Checkout\Requests\InitiateCheckoutRequest;
use App\Modules\Checkout\Services\CheckoutService;
use App\Modules\Checkout\Services\StripeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class CheckoutController
{
    public function __construct(
        private readonly CheckoutService $checkoutService,
        private readonly StripeService $stripeService,
    ) {}

    public function store(InitiateCheckoutRequest $request): JsonResponse
    {
        $result = $this->checkoutService->initiate(
            $request->user(),
            $request->validated('shipping_address'),
            $request->validated('excluded_item_ids', []),
        );

        // If result is a 409 conflict response, it was already returned
        if (isset($result['error'])) {
            return response()->json($result, 409);
        }

        return response()->json(['data' => $result]);
    }

    public function destroy(Request $request, string $checkoutId): JsonResponse
    {
        $this->checkoutService->cancel($request->user(), $checkoutId);

        return response()->json(null, 204);
    }

    public function webhook(Request $request): JsonResponse
    {
        $event = $this->stripeService->verifyWebhookSignature(
            $request->getContent(),
            $request->header('Stripe-Signature', ''),
        );

        if ($event->type === 'payment_intent.succeeded') {
            $paymentIntentId = $event->data->object->id;
            $this->checkoutService->fulfillPayment($paymentIntentId);
        }

        return response()->json(['status' => 'ok']);
    }
}
```

- [ ] **Step 5: Create InitiateCheckoutRequest**

Create `api/app/Modules/Checkout/Requests/InitiateCheckoutRequest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Checkout\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    public function rules(): array
    {
        return [
            'shipping_address' => ['required', 'array'],
            'shipping_address.first_name' => ['required', 'string', 'max:255'],
            'shipping_address.last_name' => ['required', 'string', 'max:255'],
            'shipping_address.street' => ['required', 'string', 'max:255'],
            'shipping_address.city' => ['required', 'string', 'max:255'],
            'shipping_address.state' => ['required', 'string', 'max:2'],
            'shipping_address.zip' => ['required', 'string', 'max:10'],
            'excluded_item_ids' => ['sometimes', 'array'],
            'excluded_item_ids.*' => ['uuid'],
        ];
    }
}
```

- [ ] **Step 6: Create Checkout routes**

Replace contents of `api/app/Modules/Checkout/routes.php`:

```php
<?php

declare(strict_types=1);

use App\Modules\Checkout\Controllers\CheckoutController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/checkout', [CheckoutController::class, 'store']);
    Route::delete('/checkout/{checkoutId}', [CheckoutController::class, 'destroy']);
});

Route::post('/stripe/webhook', [CheckoutController::class, 'webhook']);
```

- [ ] **Step 7: Register Checkout routes in api.php**

In `api/routes/api.php`, add after the Cart require:

```php
    require app_path('Modules/Checkout/routes.php');
```

- [ ] **Step 8: Exclude webhook route from CSRF/auth middleware**

The Stripe webhook route needs to accept raw POST bodies without CSRF or Sanctum auth. Verify that the route is outside the `auth:sanctum` middleware group in the routes file (it already is per Step 6). Also, ensure the webhook route can read the raw body by checking Laravel's middleware stack doesn't consume it. If using `VerifyCsrfToken` middleware, add `/v1/stripe/webhook` to the `$except` array in `api/bootstrap/app.php` or equivalent middleware configuration.

- [ ] **Step 9: Run tests**

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=CheckoutTest
```
Expected: All tests pass.

- [ ] **Step 10: Commit**

```bash
git add api/app/Modules/Checkout/ api/routes/api.php api/tests/Feature/Checkout/CheckoutTest.php
git commit -m "feat(checkout): add CheckoutService, controller, and routes with tests"
```

---

## Task 9: Webhook Fulfillment Tests

**Files:**
- Create: `api/tests/Feature/Checkout/WebhookTest.php`

- [ ] **Step 1: Write webhook fulfillment tests**

Create `api/tests/Feature/Checkout/WebhookTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Checkout;

use App\Models\Cart;
use App\Models\CartItem;
use App\Models\Category;
use App\Models\Item;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\StoreSettings;
use App\Models\User;
use App\Modules\Checkout\Services\CheckoutService;
use App\Modules\Checkout\Services\StripeService;
use App\Support\Enums\ItemStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Redis;
use Mockery;
use Tests\TestCase;

class WebhookTest extends TestCase
{
    use RefreshDatabase;

    private User $buyer;
    private Store $store;
    private Category $category;

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

        $this->store = Store::factory()->verified()->create([
            'stripe_connect_id' => 'acct_test123',
        ]);
        StoreSettings::factory()->create([
            'store_id' => $this->store->id,
            'flat_shipping_rate' => 1200,
        ]);
        $this->category = Category::factory()->create();
        $this->buyer = User::factory()->create();
        $this->buyer->assignRole('buyer');
    }

    protected function tearDown(): void
    {
        Redis::flushdb();
        parent::tearDown();
    }

    public function test_fulfillment_creates_purchase_and_orders(): void
    {
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Active,
            'price' => 10000,
            'title' => 'Test Item',
        ]);

        // Set up checkout data in cache (simulating what initiate would do)
        $checkoutId = 'test-checkout-id';
        cache()->put("checkout:{$checkoutId}", [
            'user_id' => $this->buyer->id,
            'payment_intent_id' => 'pi_test_fulfill',
            'shipping_address' => [
                'first_name' => 'Jane',
                'last_name' => 'Smith',
                'street' => '123 Main St',
                'city' => 'Portland',
                'state' => 'OR',
                'zip' => '97201',
            ],
            'stores' => [[
                'store' => ['id' => $this->store->id, 'name' => $this->store->name],
                'items' => [['id' => $item->id, 'title' => 'Test Item', 'price' => 10000]],
                'subtotal' => 10000,
                'shipping' => 1200,
            ]],
            'subtotal' => 10000,
            'discount_total' => 0,
            'shipping_total' => 1200,
            'tax_total' => 0,
            'total' => 11200,
            'item_ids' => [$item->id],
        ], now()->addMinutes(30));

        // Mock Stripe for fulfillment
        $mockStripe = Mockery::mock(StripeService::class);
        $mockStripe->shouldReceive('createTransfer')->andReturn(
            new class { public string $id = 'tr_test123'; }
        );
        $mockStripe->shouldReceive('verifyWebhookSignature')->andReturn(null);
        $this->app->instance(StripeService::class, $mockStripe);

        // Mock PaymentIntent::retrieve
        $mockPaymentIntent = new class {
            public string $id = 'pi_test_fulfill';
            public object $metadata;
            public function __construct() {
                $this->metadata = (object) ['checkout_id' => 'test-checkout-id'];
            }
        };

        // Call fulfillPayment directly
        $checkoutService = app(CheckoutService::class);
        $checkoutService->fulfillPayment('pi_test_fulfill');

        // Verify purchase created
        $this->assertDatabaseHas('purchases', [
            'buyer_id' => $this->buyer->id,
            'stripe_payment_intent_id' => 'pi_test_fulfill',
            'subtotal' => 10000,
            'total' => 11200,
            'status' => 'paid',
        ]);

        // Verify order created with correct platform fee
        $purchase = Purchase::where('stripe_payment_intent_id', 'pi_test_fulfill')->first();
        $this->assertNotNull($purchase);
        $this->assertDatabaseHas('orders', [
            'purchase_id' => $purchase->id,
            'store_id' => $this->store->id,
            'subtotal' => 10000,
            'platform_fee' => 1500, // 15%
            'seller_payout' => 8500,
        ]);

        // Verify order items created with snapshots
        $this->assertDatabaseHas('order_items', [
            'item_id' => $item->id,
            'price_snapshot' => 10000,
            'title_snapshot' => 'Test Item',
        ]);

        // Verify item marked as sold
        $item->refresh();
        $this->assertEquals(ItemStatus::Sold, $item->status);
        $this->assertNotNull($item->sold_at);
    }

    public function test_duplicate_webhook_is_idempotent(): void
    {
        // Create a purchase to simulate already-processed webhook
        Purchase::factory()->create([
            'buyer_id' => $this->buyer->id,
            'stripe_payment_intent_id' => 'pi_already_processed',
        ]);

        $mockStripe = Mockery::mock(StripeService::class);
        $this->app->instance(StripeService::class, $mockStripe);

        $checkoutService = app(CheckoutService::class);
        $checkoutService->fulfillPayment('pi_already_processed');

        // Should not create a second purchase
        $this->assertDatabaseCount('purchases', 1);
    }
}
```

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

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=WebhookTest
```

Note: The `fulfillPayment` test calls `PaymentIntent::retrieve` which hits Stripe. This test may need adjustment to mock that call. If it fails, wrap the `PaymentIntent::retrieve` in `StripeService` and mock it. Update `CheckoutService::fulfillPayment` to use `$this->stripeService->getPaymentIntent($paymentIntentId)` instead.

- [ ] **Step 3: Commit**

```bash
git add api/tests/Feature/Checkout/WebhookTest.php
git commit -m "test(checkout): add webhook fulfillment tests"
```

---

## Task 10: Orders Module — Purchase & Order Endpoints

**Files:**
- Create: `api/app/Modules/Orders/Controllers/PurchaseController.php`
- Create: `api/app/Modules/Orders/Controllers/OrderController.php`
- Create: `api/app/Modules/Orders/Resources/PurchaseSummaryResource.php`
- Create: `api/app/Modules/Orders/Resources/PurchaseDetailResource.php`
- Create: `api/app/Modules/Orders/Resources/OrderDetailResource.php`
- Create: `api/app/Modules/Orders/Resources/OrderItemResource.php`
- Modify: `api/app/Modules/Orders/routes.php`
- Create: `api/tests/Feature/Orders/OrderTest.php`

- [ ] **Step 1: Write order tests**

Create `api/tests/Feature/Orders/OrderTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Orders;

use App\Models\Category;
use App\Models\Item;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Purchase;
use App\Models\Store;
use App\Models\StoreSettings;
use App\Models\User;
use App\Support\Enums\ItemStatus;
use App\Support\Enums\OrderStatus;
use App\Support\Enums\PurchaseStatus;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderTest extends TestCase
{
    use RefreshDatabase;

    private User $buyer;
    private User $seller;
    private Store $store;
    private Category $category;

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

        $this->store = Store::factory()->verified()->create();
        StoreSettings::factory()->create(['store_id' => $this->store->id]);
        $this->seller = User::factory()->create(['store_id' => $this->store->id]);
        $this->seller->assignRole('seller');
        $this->buyer = User::factory()->create();
        $this->buyer->assignRole('buyer');
        $this->category = Category::factory()->create();
    }

    public function test_buyer_can_list_purchases(): void
    {
        Purchase::factory()->count(3)->create(['buyer_id' => $this->buyer->id]);
        Purchase::factory()->create(); // another buyer's purchase

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->getJson('/v1/purchases');

        $response->assertStatus(200)
            ->assertJsonCount(3, 'data')
            ->assertJsonStructure([
                'data' => [['id', 'total', 'status', 'created_at']],
                'meta' => ['current_page', 'last_page', 'per_page', 'total'],
            ]);
    }

    public function test_buyer_can_view_purchase_detail(): void
    {
        $purchase = Purchase::factory()->create(['buyer_id' => $this->buyer->id]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $this->store->id,
        ]);
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
            'status' => ItemStatus::Sold,
        ]);
        OrderItem::factory()->create([
            'order_id' => $order->id,
            'item_id' => $item->id,
            'price_snapshot' => 5000,
            'title_snapshot' => 'Test Item',
        ]);

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->getJson("/v1/purchases/{$purchase->id}");

        $response->assertStatus(200)
            ->assertJsonPath('data.id', $purchase->id)
            ->assertJsonStructure([
                'data' => ['id', 'subtotal', 'total', 'status', 'shipping_address', 'orders' => [['id', 'store', 'items']]],
            ]);
    }

    public function test_buyer_cannot_view_other_buyers_purchase(): void
    {
        $otherPurchase = Purchase::factory()->create();

        $response = $this->actingAs($this->buyer, 'sanctum')
            ->getJson("/v1/purchases/{$otherPurchase->id}");

        $response->assertStatus(404);
    }

    public function test_seller_can_list_store_orders(): void
    {
        $purchase = Purchase::factory()->create(['buyer_id' => $this->buyer->id]);
        Order::factory()->count(2)->create([
            'purchase_id' => $purchase->id,
            'store_id' => $this->store->id,
        ]);
        Order::factory()->create(['purchase_id' => $purchase->id]); // another store

        $response = $this->actingAs($this->seller, 'sanctum')
            ->getJson("/v1/stores/{$this->store->id}/orders");

        $response->assertStatus(200)
            ->assertJsonCount(2, 'data');
    }

    public function test_seller_can_view_order_detail(): void
    {
        $purchase = Purchase::factory()->create(['buyer_id' => $this->buyer->id]);
        $order = Order::factory()->create([
            'purchase_id' => $purchase->id,
            'store_id' => $this->store->id,
        ]);
        $item = Item::factory()->create([
            'store_id' => $this->store->id,
            'category_id' => $this->category->id,
        ]);
        OrderItem::factory()->create([
            'order_id' => $order->id,
            'item_id' => $item->id,
        ]);

        $response = $this->actingAs($this->seller, 'sanctum')
            ->getJson("/v1/stores/{$this->store->id}/orders/{$order->id}");

        $response->assertStatus(200)
            ->assertJsonPath('data.id', $order->id)
            ->assertJsonStructure([
                'data' => ['id', 'store', 'subtotal', 'shipping_cost', 'status', 'items'],
            ]);
    }

    public function test_unauthenticated_cannot_access_purchases(): void
    {
        $this->getJson('/v1/purchases')->assertStatus(401);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=OrderTest
```

- [ ] **Step 3: Create PurchaseController**

Create `api/app/Modules/Orders/Controllers/PurchaseController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Controllers;

use App\Models\Purchase;
use App\Modules\Orders\Resources\PurchaseDetailResource;
use App\Modules\Orders\Resources\PurchaseSummaryResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class PurchaseController
{
    public function index(Request $request): JsonResponse
    {
        $purchases = Purchase::where('buyer_id', $request->user()->id)
            ->withCount('orders')
            ->latest()
            ->paginate($request->query('per_page', 15));

        return PurchaseSummaryResource::collection($purchases)->response();
    }

    public function show(Request $request, Purchase $purchase): JsonResponse
    {
        if ($purchase->buyer_id !== $request->user()->id) {
            abort(404);
        }

        $purchase->load(['orders.store:id,name', 'orders.orderItems']);

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

- [ ] **Step 4: Create OrderController**

Create `api/app/Modules/Orders/Controllers/OrderController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Controllers;

use App\Models\Order;
use App\Models\Store;
use App\Modules\Orders\Resources\OrderDetailResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class OrderController
{
    public function index(Request $request, Store $store): JsonResponse
    {
        $orders = $store->orders()
            ->with(['store:id,name', 'orderItems'])
            ->latest()
            ->paginate($request->query('per_page', 15));

        return OrderDetailResource::collection($orders)->response();
    }

    public function show(Store $store, Order $order): JsonResponse
    {
        if ($order->store_id !== $store->id) {
            abort(404);
        }

        $order->load(['store:id,name', 'orderItems']);

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

- [ ] **Step 5: Create Resources**

Create `api/app/Modules/Orders/Resources/PurchaseSummaryResource.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Resources;

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

class PurchaseSummaryResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'total' => $this->total,
            'status' => $this->status->value,
            'order_count' => $this->orders_count,
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}
```

Create `api/app/Modules/Orders/Resources/PurchaseDetailResource.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Resources;

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

class PurchaseDetailResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'subtotal' => $this->subtotal,
            'discount_total' => $this->discount_total,
            'shipping_total' => $this->shipping_total,
            'tax_total' => $this->tax_total,
            'total' => $this->total,
            'status' => $this->status->value,
            'shipping_address' => $this->shipping_address,
            'orders' => OrderDetailResource::collection($this->orders),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}
```

Create `api/app/Modules/Orders/Resources/OrderDetailResource.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Resources;

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

class OrderDetailResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store' => [
                'id' => $this->store->id,
                'name' => $this->store->name,
            ],
            'subtotal' => $this->subtotal,
            'shipping_cost' => $this->shipping_cost,
            'status' => $this->status->value,
            'items' => OrderItemResource::collection($this->orderItems),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}
```

Create `api/app/Modules/Orders/Resources/OrderItemResource.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Orders\Resources;

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

class OrderItemResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'item_id' => $this->item_id,
            'title_snapshot' => $this->title_snapshot,
            'price_snapshot' => $this->price_snapshot,
            'image_url_snapshot' => $this->image_url_snapshot,
        ];
    }
}
```

- [ ] **Step 6: Create Orders routes**

Replace contents of `api/app/Modules/Orders/routes.php`:

```php
<?php

declare(strict_types=1);

use App\Modules\Orders\Controllers\OrderController;
use App\Modules\Orders\Controllers\PurchaseController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    // Buyer purchase history
    Route::get('/purchases', [PurchaseController::class, 'index']);
    Route::get('/purchases/{purchase}', [PurchaseController::class, 'show']);

    // Seller order management
    Route::middleware('store.owner')->group(function () {
        Route::get('/stores/{store}/orders', [OrderController::class, 'index']);
        Route::get('/stores/{store}/orders/{order}', [OrderController::class, 'show']);
    });
});
```

- [ ] **Step 7: Register Orders routes in api.php**

In `api/routes/api.php`, add after the Checkout require:

```php
    require app_path('Modules/Orders/routes.php');
```

- [ ] **Step 8: Run tests**

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=OrderTest
```
Expected: All tests pass.

- [ ] **Step 9: Commit**

```bash
git add api/app/Modules/Orders/ api/routes/api.php api/tests/Feature/Orders/
git commit -m "feat(orders): add purchase and order endpoints with tests"
```

---

## Task 11: Stripe Connect Onboarding Endpoint

**Files:**
- Create: `api/app/Modules/Stores/Controllers/StripeConnectController.php`
- Modify: `api/app/Modules/Stores/routes.php`
- Create: `api/tests/Feature/Stores/StripeConnectTest.php`

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

Create `api/tests/Feature/Stores/StripeConnectTest.php`:

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Stores;

use App\Models\Store;
use App\Models\StoreSettings;
use App\Models\User;
use App\Modules\Checkout\Services\StripeService;
use Database\Seeders\RoleAndPermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;

class StripeConnectTest extends TestCase
{
    use RefreshDatabase;

    private User $seller;
    private Store $store;

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

        $this->store = Store::factory()->verified()->create();
        StoreSettings::factory()->create(['store_id' => $this->store->id]);
        $this->seller = User::factory()->create(['store_id' => $this->store->id]);
        $this->seller->assignRole('seller');

        $mockStripe = Mockery::mock(StripeService::class);
        $mockStripe->shouldReceive('createConnectAccount')
            ->andReturn('https://connect.stripe.com/setup/test');
        $this->app->instance(StripeService::class, $mockStripe);
    }

    public function test_seller_can_create_connect_link(): void
    {
        $response = $this->actingAs($this->seller, 'sanctum')
            ->postJson("/v1/stores/{$this->store->id}/stripe/connect");

        $response->assertStatus(200)
            ->assertJsonPath('data.onboarding_url', 'https://connect.stripe.com/setup/test');
    }

    public function test_non_owner_cannot_create_connect_link(): void
    {
        $otherStore = Store::factory()->verified()->create();
        StoreSettings::factory()->create(['store_id' => $otherStore->id]);

        $response = $this->actingAs($this->seller, 'sanctum')
            ->postJson("/v1/stores/{$otherStore->id}/stripe/connect");

        $response->assertStatus(403);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

- [ ] **Step 3: Create StripeConnectController**

Create `api/app/Modules/Stores/Controllers/StripeConnectController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Modules\Stores\Controllers;

use App\Models\Store;
use App\Modules\Checkout\Services\StripeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StripeConnectController
{
    public function __construct(
        private readonly StripeService $stripeService,
    ) {}

    public function store(Request $request, Store $store): JsonResponse
    {
        $frontendUrl = config('app.frontend_url', 'http://localhost:3000');

        $url = $this->stripeService->createConnectAccount(
            $store,
            returnUrl: "{$frontendUrl}/settings?connected=true",
            refreshUrl: "{$frontendUrl}/settings",
        );

        return response()->json([
            'data' => [
                'onboarding_url' => $url,
            ],
        ]);
    }
}
```

- [ ] **Step 4: Add route to Stores module**

Add to `api/app/Modules/Stores/routes.php`, inside the `auth:sanctum` + `store.owner` middleware group:

```php
Route::post('/stores/{store}/stripe/connect', [StripeConnectController::class, 'store']);
```

And add the import at the top:

```php
use App\Modules\Stores\Controllers\StripeConnectController;
```

- [ ] **Step 5: Run tests**

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test --filter=StripeConnectTest
```

- [ ] **Step 6: Commit**

```bash
git add api/app/Modules/Stores/Controllers/StripeConnectController.php api/app/Modules/Stores/routes.php api/tests/Feature/Stores/StripeConnectTest.php
git commit -m "feat(stores): add Stripe Express Connect onboarding endpoint"
```

---

## Task 12: Frontend — API Client Endpoints & Types

**Files:**
- Create: `packages/api-client/src/endpoints/cart.ts`
- Create: `packages/api-client/src/endpoints/checkout.ts`
- Create: `packages/api-client/src/endpoints/purchases.ts`
- Create: `packages/api-client/src/endpoints/orders.ts`
- Create: `packages/api-client/src/endpoints/stripe.ts`
- Modify: `packages/api-client/src/index.ts`
- Modify: `web/src/lib/api.ts`

- [ ] **Step 1: Create cart endpoints**

Create `packages/api-client/src/endpoints/cart.ts`:

```typescript
import type { AlqoveClient } from '../client';

export interface CartData {
  stores: CartStoreGroup[];
  item_count: number;
  subtotal: number;
  shipping_total: number;
  total: number;
}

export interface CartStoreGroup {
  store: { id: string; name: string; logo_image: string | null };
  items: CartItemData[];
  subtotal: number;
  shipping: number;
  free_shipping_threshold: number | null;
}

export interface CartItemData {
  id: string;
  item: {
    id: string;
    title: string;
    price: number;
    image_url: string | null;
    condition: string;
    is_available: boolean;
  };
  added_at: string;
}

export function createCartEndpoints(client: AlqoveClient) {
  return {
    get() {
      return client.get<CartData>('/v1/cart');
    },

    addItem(itemId: string) {
      return client.post<CartItemData>('/v1/cart/items', { item_id: itemId });
    },

    removeItem(itemId: string) {
      return client.delete<void>(`/v1/cart/items/${itemId}`);
    },
  };
}
```

- [ ] **Step 2: Create checkout endpoints**

Create `packages/api-client/src/endpoints/checkout.ts`:

```typescript
import type { AlqoveClient } from '../client';

export interface ShippingAddress {
  first_name: string;
  last_name: string;
  street: string;
  city: string;
  state: string;
  zip: string;
}

export interface CheckoutData {
  checkout_id: string;
  client_secret: string;
  stores: CheckoutStoreGroup[];
  subtotal: number;
  discount_total: number;
  shipping_total: number;
  tax_total: number;
  total: number;
}

export interface CheckoutStoreGroup {
  store: { id: string; name: string };
  items: { id: string; title: string; price: number }[];
  subtotal: number;
  shipping: number;
}

export interface CheckoutConflict {
  error: string;
  unavailable_items: { id: string; title: string; reason: string }[];
  available_items: { id: string; title: string; price: number }[];
  updated_totals: { subtotal: number; shipping_total: number; total: number };
}

export function createCheckoutEndpoints(client: AlqoveClient) {
  return {
    initiate(shippingAddress: ShippingAddress, excludedItemIds: string[] = []) {
      return client.post<CheckoutData>('/v1/checkout', {
        shipping_address: shippingAddress,
        excluded_item_ids: excludedItemIds,
      });
    },

    cancel(checkoutId: string) {
      return client.delete<void>(`/v1/checkout/${checkoutId}`);
    },
  };
}
```

- [ ] **Step 3: Create purchases endpoints**

Create `packages/api-client/src/endpoints/purchases.ts`:

```typescript
import type { PaginatedResponse } from '@alqove/types';
import type { AlqoveClient } from '../client';

export interface PurchaseSummary {
  id: string;
  total: number;
  status: string;
  order_count: number;
  created_at: string;
}

export interface PurchaseDetail {
  id: string;
  subtotal: number;
  discount_total: number;
  shipping_total: number;
  tax_total: number;
  total: number;
  status: string;
  shipping_address: {
    first_name: string;
    last_name: string;
    street: string;
    city: string;
    state: string;
    zip: string;
  };
  orders: OrderDetail[];
  created_at: string;
}

export interface OrderDetail {
  id: string;
  store: { id: string; name: string };
  subtotal: number;
  shipping_cost: number;
  status: string;
  items: OrderItemData[];
  created_at: string;
}

export interface OrderItemData {
  id: string;
  item_id: string;
  title_snapshot: string;
  price_snapshot: number;
  image_url_snapshot: string | null;
}

export function createPurchaseEndpoints(client: AlqoveClient) {
  return {
    list(params?: Record<string, string>) {
      return client.get<PaginatedResponse<PurchaseSummary>>('/v1/purchases', params);
    },

    get(purchaseId: string) {
      return client.get<PurchaseDetail>(`/v1/purchases/${purchaseId}`);
    },
  };
}
```

- [ ] **Step 4: Create orders endpoints (seller-side)**

Create `packages/api-client/src/endpoints/orders.ts`:

```typescript
import type { PaginatedResponse } from '@alqove/types';
import type { AlqoveClient } from '../client';
import type { OrderDetail } from './purchases';

export function createOrderEndpoints(client: AlqoveClient) {
  return {
    list(storeId: string, params?: Record<string, string>) {
      return client.get<PaginatedResponse<OrderDetail>>(
        `/v1/stores/${storeId}/orders`,
        params
      );
    },

    get(storeId: string, orderId: string) {
      return client.get<OrderDetail>(
        `/v1/stores/${storeId}/orders/${orderId}`
      );
    },
  };
}
```

- [ ] **Step 5: Create Stripe endpoints**

Create `packages/api-client/src/endpoints/stripe.ts`:

```typescript
import type { AlqoveClient } from '../client';

export function createStripeEndpoints(client: AlqoveClient) {
  return {
    createConnectLink(storeId: string) {
      return client.post<{ onboarding_url: string }>(
        `/v1/stores/${storeId}/stripe/connect`
      );
    },
  };
}
```

- [ ] **Step 6: Update api-client index.ts**

Update `packages/api-client/src/index.ts`:

```typescript
/**
 * @alqove/api-client
 *
 * Typed API client for the Alqove marketplace.
 * Both web and mobile import this — neither writes raw fetch calls.
 */

export { AlqoveClient } from './client';
export type { ClientConfig } from './client';

export { createAuthEndpoints } from './endpoints/auth';
export { createUserEndpoints } from './endpoints/users';
export { createStoreEndpoints } from './endpoints/stores';
export { createItemEndpoints } from './endpoints/items';
export { createCategoryEndpoints } from './endpoints/categories';
export { createCartEndpoints } from './endpoints/cart';
export { createCheckoutEndpoints } from './endpoints/checkout';
export { createPurchaseEndpoints } from './endpoints/purchases';
export { createOrderEndpoints } from './endpoints/orders';
export { createStripeEndpoints } from './endpoints/stripe';

export type { ItemSearchQueryInput, ItemBrowseResponse } from './endpoints/items';
export type { StorePublic } from './endpoints/stores';
export type { CategoryTree } from './endpoints/categories';
export type { CartData, CartStoreGroup, CartItemData } from './endpoints/cart';
export type { CheckoutData, CheckoutConflict, ShippingAddress, CheckoutStoreGroup } from './endpoints/checkout';
export type { PurchaseSummary, PurchaseDetail, OrderDetail, OrderItemData } from './endpoints/purchases';
```

- [ ] **Step 7: Update web api.ts**

Update `web/src/lib/api.ts`:

```typescript
import {
  AlqoveClient,
  createAuthEndpoints,
  createUserEndpoints,
  createStoreEndpoints,
  createItemEndpoints,
  createCategoryEndpoints,
  createCartEndpoints,
  createCheckoutEndpoints,
  createPurchaseEndpoints,
  createOrderEndpoints,
  createStripeEndpoints,
} from '@alqove/api-client';

const client = new AlqoveClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000',
  getToken: () => {
    if (typeof window === 'undefined') return null;
    return localStorage.getItem('auth_token');
  },
});

export const api = {
  client,
  auth: createAuthEndpoints(client),
  users: createUserEndpoints(client),
  stores: createStoreEndpoints(client),
  items: createItemEndpoints(client),
  categories: createCategoryEndpoints(client),
  cart: createCartEndpoints(client),
  checkout: createCheckoutEndpoints(client),
  purchases: createPurchaseEndpoints(client),
  orders: createOrderEndpoints(client),
  stripe: createStripeEndpoints(client),
};
```

- [ ] **Step 8: Verify types compile**

Run:
```bash
cd Alqove && npm run typecheck
```

- [ ] **Step 9: Commit**

```bash
git add packages/api-client/ web/src/lib/api.ts
git commit -m "feat(web): add cart, checkout, orders, and Stripe API client endpoints"
```

---

## Task 13: Frontend — Zustand Cart Store & Query Hooks

**Files:**
- Create: `web/src/stores/cart.ts`
- Create: `web/src/lib/queries/use-cart.ts`
- Create: `web/src/lib/queries/use-purchases.ts`

- [ ] **Step 1: Create Zustand cart store**

Create `web/src/stores/cart.ts`:

```typescript
'use client';

import { create } from 'zustand';

interface CartState {
  itemCount: number;
  setItemCount: (count: number) => void;
  incrementCount: () => void;
  decrementCount: () => void;
}

export const useCartStore = create<CartState>((set) => ({
  itemCount: 0,
  setItemCount: (count) => set({ itemCount: count }),
  incrementCount: () => set((state) => ({ itemCount: state.itemCount + 1 })),
  decrementCount: () => set((state) => ({ itemCount: Math.max(0, state.itemCount - 1) })),
}));
```

- [ ] **Step 2: Create cart query hooks**

Create `web/src/lib/queries/use-cart.ts`:

```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { useCartStore } from '@/stores/cart';

export function useCart() {
  const setItemCount = useCartStore((s) => s.setItemCount);

  return useQuery({
    queryKey: ['cart'],
    queryFn: async () => {
      const response = await api.cart.get();
      setItemCount(response.data.item_count);
      return response;
    },
  });
}

export function useAddToCart() {
  const queryClient = useQueryClient();
  const incrementCount = useCartStore((s) => s.incrementCount);

  return useMutation({
    mutationFn: (itemId: string) => api.cart.addItem(itemId),
    onSuccess: () => {
      incrementCount();
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    },
  });
}

export function useRemoveFromCart() {
  const queryClient = useQueryClient();
  const decrementCount = useCartStore((s) => s.decrementCount);

  return useMutation({
    mutationFn: (itemId: string) => api.cart.removeItem(itemId),
    onSuccess: () => {
      decrementCount();
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    },
  });
}
```

- [ ] **Step 3: Create purchases query hooks**

Create `web/src/lib/queries/use-purchases.ts`:

```typescript
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';

export function usePurchases(page = 1) {
  return useQuery({
    queryKey: ['purchases', page],
    queryFn: () => api.purchases.list({ page: String(page) }),
  });
}

export function usePurchase(purchaseId: string) {
  return useQuery({
    queryKey: ['purchases', purchaseId],
    queryFn: () => api.purchases.get(purchaseId),
    enabled: purchaseId.length > 0,
  });
}
```

- [ ] **Step 4: Commit**

```bash
git add web/src/stores/cart.ts web/src/lib/queries/
git commit -m "feat(web): add Zustand cart store and TanStack Query hooks"
```

---

## Task 14: Frontend — Install Stripe.js & Cart Page

**Files:**
- Modify: `web/package.json`
- Create: `web/src/app/(buyer)/cart/page.tsx`
- Create: `web/src/app/(buyer)/cart/cart-client.tsx`

- [ ] **Step 1: Install Stripe.js packages**

Run:
```bash
cd Alqove/web && npm install @stripe/stripe-js @stripe/react-stripe-js
```

- [ ] **Step 2: Create cart page (server component)**

Create `web/src/app/(buyer)/cart/page.tsx`:

```tsx
import { CartClient } from './cart-client';

export const metadata = {
  title: 'Shopping Cart | Alqove',
};

export default function CartPage() {
  return <CartClient />;
}
```

- [ ] **Step 3: Create cart client component**

Create `web/src/app/(buyer)/cart/cart-client.tsx`:

```tsx
'use client';

import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { PriceDisplay } from '@/components/price-display';
import { useCart, useRemoveFromCart } from '@/lib/queries/use-cart';
import { formatPrice } from '@alqove/shared';
import type { CartStoreGroup } from '@alqove/api-client';

export function CartClient() {
  const router = useRouter();
  const { data: cartResponse, isLoading } = useCart();
  const removeFromCart = useRemoveFromCart();

  const cart = cartResponse?.data;

  if (isLoading) {
    return (
      <div className="mx-auto max-w-7xl px-4 py-8">
        <div className="animate-pulse space-y-4">
          <div className="h-8 w-48 rounded bg-slate-200" />
          <div className="h-64 rounded bg-slate-200" />
        </div>
      </div>
    );
  }

  if (!cart || cart.item_count === 0) {
    return (
      <div className="mx-auto max-w-7xl px-4 py-16 text-center">
        <h1 className="text-2xl font-bold text-slate-900">Your cart is empty</h1>
        <p className="mt-2 text-slate-500">Browse items and add them to your cart.</p>
        <Link href="/items">
          <Button className="mt-6">Browse Items</Button>
        </Link>
      </div>
    );
  }

  return (
    <div className="mx-auto max-w-7xl px-4 py-8">
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold text-slate-900">
          Shopping Cart{' '}
          <span className="text-base font-normal text-slate-400">
            ({cart.item_count} {cart.item_count === 1 ? 'item' : 'items'})
          </span>
        </h1>
        <Link href="/items" className="text-sm font-medium text-emerald-600 hover:text-emerald-700">
          Continue Shopping &rarr;
        </Link>
      </div>

      <div className="flex gap-8">
        {/* Cart items grouped by store */}
        <div className="flex-1 space-y-4">
          {cart.stores.map((storeGroup: CartStoreGroup) => (
            <div key={storeGroup.store.id} className="rounded-lg border border-slate-200 bg-white overflow-hidden">
              <div className="bg-slate-50 px-4 py-3 border-b border-slate-200 flex items-center gap-2">
                <div className="h-7 w-7 rounded-full bg-emerald-600 flex items-center justify-center text-xs font-semibold text-white">
                  {storeGroup.store.name.charAt(0)}
                </div>
                <span className="font-semibold text-sm">{storeGroup.store.name}</span>
              </div>

              {storeGroup.items.map((cartItem) => (
                <div key={cartItem.id} className="flex gap-4 p-4 border-b border-slate-100 last:border-0">
                  <div className="h-20 w-20 flex-shrink-0 rounded bg-slate-100 overflow-hidden">
                    {cartItem.item.image_url ? (
                      <img src={cartItem.item.image_url} alt={cartItem.item.title} className="h-full w-full object-cover" />
                    ) : (
                      <div className="h-full w-full flex items-center justify-center text-xs text-slate-400">No image</div>
                    )}
                  </div>
                  <div className="flex-1">
                    <Link href={`/items/${cartItem.item.id}`} className="font-semibold text-sm hover:text-emerald-600">
                      {cartItem.item.title}
                    </Link>
                    <div className="text-xs text-slate-400 mt-0.5">{cartItem.item.condition}</div>
                    <div className="mt-2 flex items-center gap-3">
                      <PriceDisplay price={cartItem.item.price} size="sm" />
                      <button
                        onClick={() => removeFromCart.mutate(cartItem.item.id)}
                        className="text-xs text-red-500 hover:text-red-700"
                        disabled={removeFromCart.isPending}
                      >
                        Remove
                      </button>
                    </div>
                  </div>
                </div>
              ))}

              <div className="bg-emerald-50 px-4 py-2 text-xs text-emerald-800 border-t border-slate-200">
                Shipping: {formatPrice(storeGroup.shipping)}
                {storeGroup.free_shipping_threshold && storeGroup.shipping > 0 && (
                  <span> &middot; Free over {formatPrice(storeGroup.free_shipping_threshold)}</span>
                )}
                {storeGroup.shipping === 0 && storeGroup.free_shipping_threshold && (
                  <span> &middot; Free shipping!</span>
                )}
              </div>
            </div>
          ))}
        </div>

        {/* Order summary sidebar */}
        <div className="w-80 flex-shrink-0">
          <div className="sticky top-6 rounded-lg border border-slate-200 bg-white p-5">
            <h2 className="font-bold text-base mb-4">Order Summary</h2>

            {cart.stores.map((sg: CartStoreGroup) => (
              <div key={sg.store.id} className="flex justify-between text-sm mb-2">
                <span className="text-slate-500">
                  {sg.store.name} ({sg.items.length} {sg.items.length === 1 ? 'item' : 'items'})
                </span>
                <span>{formatPrice(sg.subtotal)}</span>
              </div>
            ))}

            <div className="border-t border-slate-100 mt-3 pt-3 space-y-2">
              <div className="flex justify-between text-sm">
                <span className="text-slate-500">Subtotal</span>
                <span>{formatPrice(cart.subtotal)}</span>
              </div>
              <div className="flex justify-between text-sm">
                <span className="text-slate-500">Shipping</span>
                <span>{formatPrice(cart.shipping_total)}</span>
              </div>
              <div className="flex justify-between text-sm">
                <span className="text-slate-500">Discount</span>
                <span className="text-slate-400">&mdash;</span>
              </div>
            </div>

            <div className="border-t-2 border-slate-900 mt-3 pt-3 flex justify-between font-bold text-lg">
              <span>Total</span>
              <span>{formatPrice(cart.total)}</span>
            </div>

            <Button
              className="w-full mt-5"
              size="lg"
              onClick={() => router.push('/checkout')}
            >
              Proceed to Checkout
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}
```

- [ ] **Step 4: Commit**

```bash
git add web/package.json web/package-lock.json web/src/app/\(buyer\)/cart/
git commit -m "feat(web): add cart page with per-store grouping"
```

---

## Task 15: Frontend — Checkout Page

**Files:**
- Create: `web/src/app/(buyer)/checkout/page.tsx`
- Create: `web/src/app/(buyer)/checkout/checkout-client.tsx`

This task creates the checkout page with shipping address form, Stripe Elements payment form, per-store order breakdown, and the pay/cancel buttons. The implementation follows the same patterns as the cart page (server component wrapper + client component) and uses the `@stripe/react-stripe-js` library for the payment form.

Since this is a large component (address form + Stripe Elements + partial availability modal + order summary), the implementing agent should reference the wireframe layout from the design spec and use the existing shadcn/ui components (Button, Input, etc.) already in the project. The partial availability modal should appear when `POST /v1/checkout` returns a 409 response.

- [ ] **Step 1: Create checkout page and client component**

The checkout client component should:
1. Load cart data via `useCart()` to show the order breakdown
2. Manage shipping address form state
3. Call `api.checkout.initiate()` when the buyer submits the address
4. Handle 409 responses by showing a partial availability modal
5. Use `loadStripe()` with `NEXT_PUBLIC_STRIPE_PUBLIC_KEY` env var
6. Render `<PaymentElement>` from `@stripe/react-stripe-js` using the `client_secret`
7. Call `stripe.confirmPayment()` on pay button click
8. Redirect to `/purchases/{id}` on success
9. Call `api.checkout.cancel()` on cancel button click, redirect to `/cart`

- [ ] **Step 2: Verify the page renders**

Run:
```bash
cd Alqove/web && npm run dev
```
Navigate to `http://localhost:3000/checkout` and verify the page renders.

- [ ] **Step 3: Commit**

```bash
git add web/src/app/\(buyer\)/checkout/
git commit -m "feat(web): add checkout page with Stripe Elements and address form"
```

---

## Task 16: Frontend — Wire Up Item Detail "Add to Cart" Button

**Files:**
- Modify: `web/src/app/(buyer)/items/[id]/item-detail-client.tsx`

- [ ] **Step 1: Update item detail client to use real cart**

In `web/src/app/(buyer)/items/[id]/item-detail-client.tsx`:

Replace the "Add to Cart" `onClick` handler from `() => setToast("Cart coming soon")` to use the `useAddToCart` mutation:

```tsx
// Add import at top
import { useAddToCart } from '@/lib/queries/use-cart';

// Inside component, add:
const addToCart = useAddToCart();

// Replace the Add to Cart button onClick:
onClick={() => {
  addToCart.mutate(item.id, {
    onSuccess: () => setToast('Added to cart!'),
    onError: () => setToast('Item is no longer available'),
  });
}}
```

- [ ] **Step 2: Verify it works**

Navigate to an item detail page, click "Add to Cart", verify the toast says "Added to cart!" and the cart count in the header updates.

- [ ] **Step 3: Commit**

```bash
git add web/src/app/\(buyer\)/items/\[id\]/item-detail-client.tsx
git commit -m "feat(web): wire up Add to Cart button on item detail page"
```

---

## Task 17: Frontend — Cart Badge in Header & Purchase History Page

**Files:**
- Modify: Header/nav component (find the buyer layout or shared nav component)
- Create: `web/src/app/(buyer)/purchases/page.tsx`
- Create: `web/src/app/(buyer)/purchases/purchases-client.tsx`
- Create: `web/src/app/(buyer)/purchases/[id]/page.tsx`
- Create: `web/src/app/(buyer)/purchases/[id]/purchase-detail-client.tsx`

- [ ] **Step 1: Add cart badge to header**

Find the buyer layout nav component and add a cart icon with badge showing `useCartStore` item count. The cart icon should link to `/cart`.

- [ ] **Step 2: Create purchases list page**

Create the purchases list page showing the buyer's purchase history with date, total, status, and item count. Each row links to the purchase detail page. Use the `usePurchases` hook.

- [ ] **Step 3: Create purchase detail page**

Create the purchase detail page showing the full breakdown: purchase totals, shipping address, and per-store orders with order items (using snapshot data). Use the `usePurchase` hook.

- [ ] **Step 4: Commit**

```bash
git add web/src/app/\(buyer\)/purchases/ web/src/components/ web/src/app/\(buyer\)/layout.tsx
git commit -m "feat(web): add cart badge, purchases list, and purchase detail pages"
```

---

## Task 18: Frontend — Seller Payments Settings & Orders Wiring

**Files:**
- Modify: Seller settings page (add Payments tab)
- Modify: Seller orders page (wire to real API)

- [ ] **Step 1: Add Payments tab to seller settings**

In the seller settings page, add a "Payments" tab that:
- Shows a warning banner if `store.stripe_connect_id` is null ("Connect with Stripe" button calls `api.stripe.createConnectLink(storeId)` and redirects to the returned URL)
- Shows a success banner if Stripe is connected (green "Stripe Connected" state, link to Stripe dashboard)

- [ ] **Step 2: Wire seller orders page to real API**

The seller orders page already exists but isn't connected to data. Wire it to `api.orders.list(storeId)` and `api.orders.get(storeId, orderId)` to show real incoming orders.

- [ ] **Step 3: Commit**

```bash
git add web/src/app/\(seller\)/
git commit -m "feat(web): add Stripe Connect payments tab and wire seller orders"
```

---

## Task 19: Run Full Test Suite & Lint

**Files:** None (verification only)

- [ ] **Step 1: Run all Laravel tests**

Run:
```bash
cd Alqove && docker compose exec laravel.test php artisan test
```
Expected: All tests pass (existing + new).

- [ ] **Step 2: Run Pint (code style)**

Run:
```bash
cd Alqove && docker compose exec laravel.test ./vendor/bin/pint --test
```
If failures, run `./vendor/bin/pint` to auto-fix, then commit the fixes.

- [ ] **Step 3: Run PHPStan**

Run:
```bash
cd Alqove && docker compose exec laravel.test ./vendor/bin/phpstan analyse
```
Fix any static analysis errors.

- [ ] **Step 4: Run frontend typecheck and lint**

Run:
```bash
cd Alqove && npm run typecheck && npm run lint
```
Fix any errors.

- [ ] **Step 5: Run frontend build**

Run:
```bash
cd Alqove/web && npm run build
```
Verify clean production build.

- [ ] **Step 6: Commit any fixes**

```bash
git add -A
git commit -m "fix: resolve lint and type errors from Layer 4 implementation"
```

---

## Task Summary

| Task | Description | Dependencies |
|------|-------------|-------------|
| 1 | Install Stripe PHP SDK and configure | None |
| 2 | OpenAPI spec — Cart endpoints | None |
| 3 | OpenAPI spec — Checkout, Orders, Stripe Connect | None |
| 4 | CartService — add/remove/get with live pricing | 1, 2 |
| 5 | CheckoutLockService — Redis Lua script | None |
| 6 | StripeService | 1 |
| 7 | DiscountService stub | None |
| 8 | CheckoutService — initiate & cancel | 4, 5, 6, 7 |
| 9 | Webhook fulfillment tests | 8 |
| 10 | Orders module — purchase & order endpoints | 8 |
| 11 | Stripe Connect onboarding endpoint | 6 |
| 12 | Frontend API client endpoints & types | 2, 3 |
| 13 | Frontend Zustand cart store & query hooks | 12 |
| 14 | Frontend cart page | 13 |
| 15 | Frontend checkout page | 13, 14 |
| 16 | Frontend wire up "Add to Cart" button | 13 |
| 17 | Frontend cart badge & purchase history | 13 |
| 18 | Frontend seller payments & orders wiring | 12 |
| 19 | Full test suite & lint | All |
