# Layer 4: Cart & Checkout — Design Spec

## Overview

Layer 4 adds the cart system, checkout flow with Stripe payments, and order management to the Alqove marketplace. It builds on Layer 3's browse and search, wiring up the placeholder "Add to Cart" and "Save" buttons on item detail pages into a fully functional purchase pipeline.

Sellers are B2B clients (paying businesses). The platform takes a 15% fee per order. Each store receives payouts via Stripe Express Connect.

## Key Design Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Stripe Connect type | Express | Lighter onboarding for store owners, Stripe handles compliance, platform retains payout control |
| PaymentIntent strategy | Single charge with per-store Transfers | One line on buyer's card statement; transfer reversals handle cancellations cleanly |
| Partial availability | Buyer confirmation modal | Buyer sees what changed and decides whether to proceed with remaining items |
| Discount service | Thin stub returning $0 | Right interface for checkout integration; no speculative validation logic |
| Shipping | Real address form + flat-rate from StoreSettings | Production-ready feel; trivial math using existing settings |
| Checkout locking | Redis Lua script, 10min TTL, +5min extend, explicit cancel | Atomic lock acquisition prevents partial locks; cancel releases immediately |
| Checkout orchestration | Service-orchestrated (CheckoutService) | Fits existing module/service pattern; synchronous flow with event-driven webhook handling |

## Architecture

Three API modules and a webhook listener:

**Cart Module** — Adding/removing items, reading the cart. DB-backed (one cart per user, one entry per item). Prices are never stored in the cart; every read joins against the `items` table for live pricing. Cart response groups items by store.

**Checkout Module** — The orchestrator. Validates cart, acquires Redis locks via Lua script, calculates totals (per-store subtotals + flat-rate shipping + discount stub), creates a single Stripe PaymentIntent with a `transfer_group`. Explicit cancel releases locks immediately. TTL expiry is the safety net for abandoned checkouts.

**Webhook Listener** — Handles `payment_intent.succeeded`. Inside a DB transaction: creates Purchase, one Order per store, OrderItems with immutable snapshots, Stripe Transfers to each store's Connect account (minus 15% fee), marks items sold, releases locks, clears buyer's cart.

**Orders Module** — Read-only for this layer. Purchase history and order detail for buyers, incoming orders for sellers.

### Infrastructure

- **Postgres** — carts, cart_items, purchases, orders, order_items, discounts, purchase_discounts (all migrations already exist)
- **Redis** — Checkout locks via Lua script (10min TTL, +5min on PaymentIntent creation, explicit cancel releases)
- **Stripe** — Express Connect (store onboarding), PaymentIntent (single charge), Transfers (per-store payout)

## API Endpoints

### Cart Module

| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/v1/cart` | Get cart grouped by store with live prices |
| POST | `/v1/cart/items` | Add item to cart |
| DELETE | `/v1/cart/items/{item_id}` | Remove item from cart |

All cart endpoints require buyer authentication.

**GET /v1/cart** response structure:
```json
{
  "data": {
    "stores": [
      {
        "store": { "id": "uuid", "name": "...", "logo_image": "..." },
        "items": [
          {
            "id": "uuid",
            "item": { "id": "uuid", "title": "...", "price": 24500, "image_url": "...", "condition": "...", "is_available": true },
            "added_at": "2026-04-14T12:00:00Z"
          }
        ],
        "subtotal": 31300,
        "shipping": 1200,
        "free_shipping_threshold": 35000
      }
    ],
    "item_count": 3,
    "subtotal": 39800,
    "shipping_total": 2050,
    "total": 41850
  }
}
```

**POST /v1/cart/items** request: `{ "item_id": "uuid" }`. Returns 201 with the cart item. Returns 409 if item is unavailable or already sold.

**DELETE /v1/cart/items/{item_id}** returns 204 No Content.

### Checkout Module

| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/v1/checkout` | Initiate checkout: validate, lock, create PaymentIntent |
| DELETE | `/v1/checkout/{checkout_id}` | Cancel checkout, release locks |

**POST /v1/checkout** request:
```json
{
  "shipping_address": {
    "first_name": "Jane",
    "last_name": "Smith",
    "street": "123 Main St",
    "city": "Portland",
    "state": "OR",
    "zip": "97201"
  },
  "excluded_item_ids": []
}
```

The `excluded_item_ids` field is used when the buyer proceeds after the partial availability modal, passing in the IDs of items they've acknowledged are unavailable.

Success response (200):
```json
{
  "data": {
    "checkout_id": "uuid",
    "client_secret": "pi_xxx_secret_xxx",
    "stores": [
      {
        "store": { "id": "uuid", "name": "..." },
        "items": [
          { "id": "uuid", "title": "...", "price": 24500 }
        ],
        "subtotal": 31300,
        "shipping": 1200
      }
    ],
    "subtotal": 39800,
    "discount_total": 0,
    "shipping_total": 2050,
    "tax_total": 0,
    "total": 41850
  }
}
```

Conflict response (409) when items are unavailable:
```json
{
  "error": "items_unavailable",
  "unavailable_items": [
    { "id": "uuid", "title": "Mid-Century Desk Lamp", "reason": "sold" }
  ],
  "available_items": [
    { "id": "uuid", "title": "1970s Teak Credenza", "price": 24500 }
  ],
  "updated_totals": {
    "subtotal": 33000,
    "shipping_total": 2050,
    "total": 35050
  }
}
```

**DELETE /v1/checkout/{checkout_id}** cancels the PaymentIntent, releases all Redis locks. Returns 204.

### Stripe Webhook

| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/v1/stripe/webhook` | Handle payment_intent.succeeded |

Authenticated via Stripe webhook signature (no bearer token). The webhook handler:

1. Verifies the signature using `STRIPE_WEBHOOK_SECRET`
2. Extracts `checkout_id` and `user_id` from PaymentIntent metadata
3. Inside a DB transaction:
   - Creates Purchase (status: paid, stores shipping address, all totals)
   - For each store in the checkout: creates Order (subtotal, shipping, platform fee at 15%, seller payout), creates OrderItems with price/title/image snapshots
   - Creates Stripe Transfers to each store's Connect account
   - Marks all items as sold (updates item status)
   - Releases Redis checkout locks
   - Clears the buyer's cart

Idempotency: if a Purchase already exists for the PaymentIntent ID, the webhook returns 200 without creating duplicates.

### Orders Module

| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/v1/purchases` | Buyer's purchase history |
| GET | `/v1/purchases/{purchase_id}` | Purchase detail with orders |
| GET | `/v1/stores/{store}/orders` | Seller's incoming orders |
| GET | `/v1/stores/{store}/orders/{order}` | Seller order detail |

All endpoints are paginated and scoped to the authenticated user's ownership.

### Stripe Connect

| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/v1/stores/{store}/stripe/connect` | Create Express onboarding link |

Returns `{ "onboarding_url": "https://connect.stripe.com/..." }`. The seller is redirected to Stripe's hosted onboarding. On completion, Stripe redirects back to the seller's settings page. The API checks `charges_enabled` on the account and stores the `stripe_connect_id`.

## Services

### CartService

- `getCart(User): CartResource` — Loads cart with items joined to live prices, groups by store, calculates per-store subtotals and shipping from StoreSettings.
- `addItem(User, itemId): CartItem` — Validates item exists and is available, upserts cart and cart_item.
- `removeItem(User, itemId): void` — Removes the cart_item.
- `validateAvailability(Cart): AvailabilityResult` — Checks all items are still available and not locked by another checkout. Returns available and unavailable item lists.

### CheckoutService (Orchestrator)

- `initiate(User, shippingAddress, excludedItemIds): CheckoutResult` — Validates cart, checks availability, acquires Redis locks, calculates totals (subtotals + shipping + discount), creates PaymentIntent, extends locks, returns client_secret and full breakdown.
- `cancel(User, checkoutId): void` — Cancels PaymentIntent, releases locks.
- `fulfillPayment(paymentIntentId): void` — Called by webhook. DB transaction: create Purchase (paid), create Orders with 15% platform fee, create OrderItems with snapshots, create Transfers, mark items sold, release locks, clear cart.

### CheckoutLockService

Wraps all Redis lock operations. The CheckoutService never touches Redis directly.

- `acquire(checkoutId, itemIds): LockResult` — Lua script: attempts SETNX on all item keys atomically. Key format: `checkout:lock:item:{item_id}`, value: `checkout_id`, TTL: 600 seconds. Returns success or list of already-locked item IDs. If any key fails, all are rolled back (no partial locks).
- `extend(checkoutId, itemIds): void` — EXPIRE each key with additional 300 seconds.
- `release(checkoutId, itemIds): void` — DEL all keys for the checkout.

### StripeService

- `createConnectAccount(Store): string` — Creates Express account, returns onboarding URL.
- `createPaymentIntent(amount, transferGroup, metadata): PaymentIntent` — Creates PaymentIntent, returns object with client_secret.
- `cancelPaymentIntent(paymentIntentId): void` — Cancels on checkout cancellation.
- `createTransfer(amount, destination, transferGroup): Transfer` — Called per-store after payment succeeds.
- `verifyWebhookSignature(payload, signature): Event` — Validates Stripe webhook signature.

### DiscountService (Stub)

- `calculate(Cart, ?string code): DiscountResult` — Returns typed DiscountResult with amount: 0, discount: null. Interface ready for coupon implementation.
- `validate(string code): ?Discount` — Returns null.

## Frontend

### New Pages

**Cart Page (`/cart`)** — Items grouped by store in visually distinct cards. Each store group shows store avatar/name, individual items with image/title/condition/price and remove button, and a footer with shipping info (flat rate and free-shipping threshold from StoreSettings). Sticky sidebar shows order summary with per-store subtotals, total shipping, discount placeholder, and grand total. "Proceed to Checkout" calls POST /v1/checkout.

**Checkout Page (`/checkout`)** — Two-column layout. Left: shipping address form (first/last name, street, city, state, ZIP), Stripe Elements card form, and per-store order details breakdown. Right: sticky payment summary sidebar with "Pay $X" button (calls stripe.confirmPayment()), "Cancel Checkout" button (calls DELETE /v1/checkout/{id}), and a note that items are held for 10 minutes.

**Order Confirmation Page (`/purchases/{id}`)** — Shown after successful payment. Displays purchase summary, per-store order breakdown with items, and shipping address.

**Purchase History Page (`/purchases`)** — List of past purchases with date, total, status, and item count. Links to individual purchase detail pages.

### Updated Pages

**Item Detail Page** — Wire up "Add to Cart" button (currently shows "coming soon" toast) to call POST /v1/cart/items. Show success toast with link to cart.

**Seller Settings Page** — Add Payments tab with Stripe Connect onboarding. Shows warning state before connection, success state after.

**Seller Orders Page** — Wire up to GET /v1/stores/{store}/orders (page exists but isn't connected to real data).

**Site Header** — Add cart icon with item count badge from useCartStore.

### Partial Availability Modal

When POST /v1/checkout returns 409, a modal shows which items are unavailable (struck through, red highlight), recalculated totals, and two buttons: "Back to Cart" or "Continue with $X" (re-submits with excluded_item_ids).

### State Management

**Zustand `useCartStore`** — Client-side cart state for optimistic UI updates (add/remove feel instant). Stores item count for the header badge. Syncs with server state via TanStack Query invalidation.

**TanStack Query hooks:**
- `useCart()` — Fetches GET /v1/cart, provides cart data grouped by store
- `useAddToCart()` — Mutation for POST /v1/cart/items, optimistic update
- `useRemoveFromCart()` — Mutation for DELETE /v1/cart/items/{id}, optimistic update
- `useInitiateCheckout()` — Mutation for POST /v1/checkout
- `useCancelCheckout()` — Mutation for DELETE /v1/checkout/{id}
- `usePurchases()` — Fetches GET /v1/purchases
- `usePurchase(id)` — Fetches GET /v1/purchases/{id}

### API Client Updates

Add to `@alqove/api-client`:
- `createCartEndpoints()` — getCart, addItem, removeItem
- `createCheckoutEndpoints()` — initiate, cancel
- `createPurchaseEndpoints()` — list, detail
- `createOrderEndpoints()` — list, detail (seller-side)
- `createStripeEndpoints()` — createConnectLink

## Stripe Connect Onboarding

### Seller Flow

1. Seller visits Settings > Payments tab
2. Before onboarding: warning banner explains listings are visible but buyers can't check out
3. Seller clicks "Connect with Stripe"
4. API calls POST /v1/stores/{store}/stripe/connect
5. API creates Express account (or reuses existing incomplete one), generates Account Link
6. Seller is redirected to Stripe's hosted onboarding (business info, ID verification, bank account, tax info)
7. On completion, Stripe redirects back to seller's settings page
8. API checks account.charges_enabled; if true, stores stripe_connect_id
9. Settings page shows green "Stripe Connected" state with status and link to Stripe dashboard

### Checkout Guard

When a buyer initiates checkout, CheckoutService verifies every store in the cart has an active Connect account (charges_enabled = true). If any store hasn't onboarded, checkout returns an error identifying which store(s) can't accept payments. The buyer sees a message explaining they can't purchase items from that store yet.

## Redis Checkout Locking

### Lua Script (Atomic Lock Acquisition)

The script attempts SETNX on all item keys in a single atomic operation:

```
Keys: checkout:lock:item:{item_id} (one per item)
Value: checkout_id
TTL: 600 seconds (10 minutes)
```

If all SETNX succeed, the lock is acquired. If any key already exists (locked by another buyer), the script rolls back all keys it just set and returns the list of conflicting item IDs. This prevents partial locks.

### Lock Operations

- **Acquire** — Lua script: SETNX all items atomically, TTL 600s
- **Extend** — EXPIRE each key +300s when PaymentIntent is created (total 15 min from checkout start)
- **Release** — DEL all keys. Triggered by: explicit cancel, successful webhook fulfillment, or TTL expiry (automatic)

## Testing Strategy

### Cart Module Tests

- Add item to cart: happy path, item already in cart (idempotent), item unavailable, item sold
- Remove item from cart: happy path, item not in cart
- Get cart: empty cart, single store, multi-store grouping, live price accuracy
- Unauthenticated access returns 401

### Checkout Module Tests

- Initiate checkout: happy path (lock acquired, PaymentIntent created, totals correct)
- Partial availability: some items unavailable returns 409 with correct unavailable list
- Lock conflict: items locked by another buyer returns 409 with locked item IDs
- Shipping calculation: flat rate applied correctly, free shipping threshold respected
- Store without Stripe Connect blocks checkout
- Cancel checkout releases locks and cancels PaymentIntent
- Lock TTL expiry: items become available after 10 minutes (Redis integration test)

### Webhook Tests

- payment_intent.succeeded creates Purchase, Orders, OrderItems correctly
- OrderItem snapshots match item state at time of purchase
- Platform fee calculation: 15% of subtotal per order
- Transfers created with correct amounts to correct Connect accounts
- Items marked as sold after fulfillment
- Cart cleared after fulfillment
- Locks released after fulfillment
- Invalid/duplicate webhook signature rejected
- Idempotency: duplicate webhook does not create duplicate records

### Orders Module Tests

- Buyer purchase history: pagination, correct ownership scoping
- Purchase detail with nested orders and items
- Seller order list: only shows their store's orders
- Seller order detail

### Infrastructure Tests

- Lua lock script: integration test against real Redis (already in Docker)
- Stripe mocked in unit tests via StripeService interface
- Stripe webhook signature verification tested with Stripe's test helpers

## Out of Scope

These are explicitly deferred to later layers:

- **Coupons/discounts** — DiscountService stub returns $0; real coupon logic comes later
- **Tax calculation** — tax_total is 0 for now; tax service integration is a future layer
- **Carrier shipping rates** — Using flat-rate from StoreSettings; EasyPost/Shippo integration is later
- **Shipping labels and tracking** — Tracking fields exist on Order but aren't populated
- **Order status transitions** — Orders are created as "pending"; seller fulfillment flow is a later layer
- **Refunds and disputes** — Transfer reversal infrastructure exists but refund UI/flow is later
- **Email notifications** — No transactional emails in this layer
- **Save for later / wishlist** — Button remains a placeholder
