# Order Processing Backend — Phased Implementation Plan

**Audience:** Senior software engineers  
**Scope:** Backend only (Convex, Stripe, Shippo). No UI.  
**Source:** [Admin Order Processing User Stories](docs/project-documentation/storefront/admin-order-processing-user-stories.md).

---

## 1. Executive summary

This plan breaks the order-processing backend into four phases:

| Phase | Focus | Key deliverables |
|-------|--------|-------------------|
| **1** | Label creation | Resolve rate from shipment, POST Shippo transaction, persist label data, status → `processing`, timeline |
| **2** | Shippo Track Updated webhook | HTTP route, verify & parse, update order + timeline, set `delivered` on DELIVERED |
| **3** | Return/cancel only when confirmed | Enforce `status === "confirmed"` for customer return/cancel; optional schema for `labelUrl` |
| **4** | Refund idempotency & polish | Idempotent Stripe refund, optional tracking refresh action, tests |

**Already in place (no work in this plan):**

- **Schema:** `orders` (including `shippoShipmentId`, `shippingServiceCode`, `carrier`, `trackingNumber`, `trackingUrl`, `estimatedDelivery`, `actualDelivery`, `shippedAt`, `returnRequestedAt`, `returnReceivedAt`); `orderTimelineEvents` with `by_order` and `by_order_and_created_at`.
- **Model:** `recordOrderTimelineEvent` in `convex/model/orders.ts`.
- **Queries:** `orders.getTimeline` (timeline by order).
- **Orders:** `fulfillFromCheckout` (stores Shippo shipment ID and rate identifiers); `updateStatus` (admin) with timeline; `cancel` (customer, only when allowed) with timeline and stock restore; `requestReturn`, `markReturnReceived`, `getOrderForRefund`, `applyRefund`.
- **Refunds:** `returnActions.issueRefund` (admin, Stripe refund + `applyRefund`).
- **HTTP:** Clerk and Stripe webhooks in `convex/http.ts`.

**Resources:** Convex MCP, Stripe MCP, Shippo MCP (e.g. `shipments-get`, `transactions-create`, `tracking-status-get`, `webhooks-create`).

---

## 2. Phase 1 — Label creation (US-ADM-1, US-ADM-2)

**Goal:** Admin can create a shipping label for an order in status `confirmed`. On success, persist label data, set status to `processing` (or `shipped`), and record a timeline event. Enforce valid transitions and prevent duplicate labels.

### 2.1 Tasks

1. **Schema (optional)**  
   - Add `labelUrl: v.optional(v.string())` to `orders` if the team wants to store the label PDF/PNG URL from Shippo.  
   - If not stored, admin can open `tracking_url_provider` or use Shippo dashboard for the label.

2. **Shippo: GET shipment and resolve rate**  
   - In `convex/model/shippo.ts` (or a new internal helper used only by the label action):  
     - Add a function that calls Shippo **GET** `https://api.goshippo.com/shipments/{order.shippoShipmentId}` (or use Shippo MCP `shipments-get` from an action).  
     - From the response `rates` array, find the rate where `rate.servicelevel.token === order.shippingServiceCode` and `rate.provider === order.carrier`.  
     - Return that rate’s `object_id`.  
   - Handle: shipment not found, no matching rate, or multiple matches (pick first or fail explicitly).

3. **Shippo: Create transaction (purchase label)**  
   - Add a Convex **action** (e.g. in `convex/fulfillmentActions.ts` or `convex/orders.ts` with `"use node";` in a separate file if needed) that:  
     - Takes `orderId: v.id("orders")`.  
     - Runs with admin auth (e.g. resolve user via identity, then `requireAdmin`-style check via internal query).  
     - Loads order (internal query); validates `order.status === "confirmed"` and that the order has no `trackingNumber` yet (no label already created).  
     - Calls the new “resolve rate” helper to get the rate object ID from `order.shippoShipmentId`, `order.shippingServiceCode`, `order.carrier`.  
     - Calls Shippo **POST** `/transactions` with `{ rate: rateObjectId }` (and `async: true` for international if applicable).  
     - If Shippo returns async (e.g. status `QUEUED`), poll **GET** transaction until `SUCCESS` or `ERROR` (or schedule a follow-up action to poll).  
     - On success: extract `tracking_number`, `tracking_url_provider`, and optionally `label_url` from the transaction response.

4. **Persist label and update order**  
   - From the same action, call an **internal mutation** that:  
     - Receives `orderId`, `trackingNumber`, `trackingUrl` (from `tracking_url_provider`), optional `labelUrl`, and optional `eta` if Shippo provides it.  
     - Validates again that order is `confirmed` and has no existing `trackingNumber` (idempotency / duplicate-label guard).  
     - Patches order: `trackingNumber`, `trackingUrl`, `labelUrl` (if schema added), `estimatedDelivery` (if provided), `shippedAt: Date.now()`, `status: "processing"` (or `"shipped"` per team decision), `updatedAt: Date.now()`.  
     - Calls `recordOrderTimelineEvent` with `eventType: "label_created"`, `source: "admin"`, `fromStatus: "confirmed"`, `toStatus: "processing"` (or `"shipped"`), and optional `payload` (e.g. JSON with tracking number and carrier).  
   - On Shippo failure (rate expired, address issue, etc.): return a structured error (e.g. `{ success: false, code: "RATE_EXPIRED" | "SHIPPO_ERROR", message: string }`) so the client can show a clear message.

5. **Public API**  
   - Expose one public (or admin-only) **action** that orchestrates: resolve rate → create transaction → persist + timeline.  
   - Ensure only admins can call it (e.g. resolve user and check role in action, or call from a mutation that already enforces admin).

### 2.2 Acceptance (backend)

- Only orders in status `confirmed` and without an existing `trackingNumber` can receive a label.  
- After a successful label creation, order has `trackingNumber`, `trackingUrl`, `shippedAt`, and status `processing` (or `shipped`); one `label_created` timeline event exists.  
- Duplicate label creation for the same order is rejected.  
- Rate resolution uses `shippoShipmentId` + `shippingServiceCode` + `carrier`; expired or missing rate returns a clear error.

### 2.3 Dependencies and references

- Shippo: [Transactions](https://docs.goshippo.com/docs/guides_general/transactions/) (POST with rate ID).  
- Shippo MCP: `shipments-get` (ShipmentId = `order.shippoShipmentId`), `transactions-create` (request `{ rate: rateObjectId }`).  
- Existing: `convex/model/shippo.ts` (GET shipment not present today; add or call from action via fetch/MCP).

---

## 3. Phase 2 — Shippo Track Updated webhook (US-ADM-3)

**Goal:** Receive Shippo “Track Updated” webhooks, verify and parse payload, update the order (tracking status, ETA, and on DELIVERED set `status: "delivered"`, `actualDelivery`), and append a `tracking_update` event to `orderTimelineEvents`. No manual refresh required for live tracking.

### 3.1 Tasks

1. **HTTP route**  
   - In `convex/http.ts`, add a route, e.g. `POST /shippo/webhook`.  
   - Handler: `httpAction` that reads body, optionally verifies Shippo signature (if Shippo supports it; check [Shippo webhooks](https://docs.goshippo.com/docs/tracking/tracking)), then calls an internal action with the raw body (or parsed JSON).

2. **Webhook handler action**  
   - Implement an **internal action** (e.g. `internal.fulfillmentActions.handleShippoTrackUpdated` or `internal.shippoWebhook.handleTrackUpdated`) that:  
     - Parses the JSON payload (carrier, tracking_number, tracking_status, status_details, location, eta, etc.).  
     - Finds the order: query by `trackingNumber` + `carrier`. Add an index on `orders` e.g. `by_tracking_number_and_carrier: ["trackingNumber", "carrier"]` for efficient lookup (only orders with a label have these set).  
     - If no order found, return 200 anyway (avoid retries for bad data) and log.  
     - If order found: call an **internal mutation** that:  
       - Updates the order: set latest tracking state (e.g. store a minimal `trackingStatus` / `trackingStatusDetails` on `orders` if desired for display; otherwise derive from timeline).  
       - Sets `estimatedDelivery` from webhook `eta` if provided.  
       - If tracking status is **DELIVERED**: set `order.status = "delivered"`, `actualDelivery = Date.now()`, `updatedAt = Date.now()`.  
       - Inserts one `orderTimelineEvents` row: `eventType: "tracking_update"`, `source: "shippo_webhook"`, `payload: JSON.stringify(webhookPayload)`, `createdAt: Date.now()`.

3. **Idempotency / duplicates**  
   - Shippo may send duplicate events. Options: (a) store last processed `tracking_status` + timestamp on the order and skip if same status already applied; (b) or accept duplicate timeline events and treat UI as “last event wins” for status. Prefer (a) if you want a clean timeline.

4. **Shippo webhook registration**  
   - Document or script: register in Shippo dashboard (or via Shippo MCP `webhooks-create`) a webhook with `event: "track_updated"` and URL `https://<deployment>.convex.site/shippo/webhook`. Use production URL and live API key for live tracking.

### 3.2 Optional: “Refresh tracking” action

- Add an **action** (admin-only) that takes `orderId`, loads order, then calls Shippo **GET** tracking (e.g. `tracks/{carrier}/{tracking_number}` or Shippo MCP `tracking-status-get`) and then updates the order + appends a `tracking_update` timeline event from the response. Use for debugging or one-off backfills; primary source remains the webhook.

### 3.3 Acceptance (backend)

- POST to `/shippo/webhook` with a valid Track Updated payload updates the correct order and appends a `tracking_update` event.  
- When status is DELIVERED, order `status` becomes `delivered` and `actualDelivery` is set.  
- Unknown tracking numbers or missing orders do not cause 5xx (return 200 and log).

### 3.4 Dependencies and references

- Shippo: [Track live shipments](https://docs.goshippo.com/docs/tracking/tracking), webhook payload shape.  
- Shippo MCP: `webhooks-create` (event `track_updated`, url = Convex HTTP URL); `tracking-status-get` for optional refresh.

---

## 4. Phase 3 — Return / cancel only when confirmed (US-ADM-4)

**Goal:** Enforce in the backend that customer-initiated return and cancel are allowed **only** when `order.status === "confirmed"`. Align `canCustomerRequestReturn` (or equivalent) with this rule so that once the order moves to `processing`, `shipped`, or `delivered`, return/cancel from the storefront flow is rejected.

### 4.1 Tasks

1. **Cancel (already aligned)**  
   - `orders.cancel` already uses `canCustomerCancel(order)` which allows only `confirmed`. No change required unless you want to explicitly document or add a test that only `confirmed` is allowed.

2. **Return request**  
   - Today `canCustomerRequestReturn` allows return only when `order.status === "delivered"`. Per user stories, **return is only allowed while status is confirmed**.  
   - Update `canCustomerRequestReturn` in `convex/model/orders.ts` so that it returns `allowed: true` only when `order.status === "confirmed"` (and optionally not already cancelled/refunded/return requested).  
   - Adjust `orders.requestReturn` so that it uses this updated helper and rejects when not allowed.  
   - If the product decision is “return when confirmed = cancel”, you may keep a single cancel flow and treat “return” as cancel when confirmed; otherwise keep `requestReturn` for a distinct “return requested” state that stays in `confirmed` until admin processes it.

3. **Validation in mutations**  
   - Ensure any customer-facing mutation that cancels or requests a return checks the same rule: `status === "confirmed"`. Add an explicit guard at the start of `cancel` and `requestReturn` (e.g. “only confirmed orders can be cancelled/returned”) so the contract is clear.

4. **Timeline**  
   - When a return is requested (while confirmed), keep recording the existing `return_requested` (or equivalent) timeline event with `source: "customer_return"`.

### 4.2 Acceptance (backend)

- Customer cancel and customer return request succeed only when `order.status === "confirmed"`.  
- For `processing`, `shipped`, or `delivered`, both flows return a clear error (e.g. “Return/cancel not available; contact support”).

---

## 5. Phase 4 — Refund idempotency and polish (US-ADM-5)

**Goal:** Make refunds idempotent (no double Stripe refund if admin clicks twice), and add optional tracking refresh and tests.

### 5.1 Tasks

1. **Idempotent Stripe refund**  
   - In `returnActions.issueRefund` (or equivalent): before calling `stripe.refunds.create`, list existing refunds for the payment intent (e.g. `stripe.refunds.list({ payment_intent: order.stripePaymentIntentId })`).  
   - If a refund already exists for the full amount (or the intended amount), skip creating a new one and still call `internal.orders.applyRefund` only if the order is not yet `refunded` (so order and payment status are updated once).  
   - If partial refunds are in scope, define the rule (e.g. “only one full refund” or “sum of refunds < total”) and implement accordingly.

2. **Order guard in applyRefund**  
   - In `orders.applyRefund`, at the start: if `order.paymentStatus === "refunded"` (and optionally `order.status === "refunded"`), skip patching and timeline (idempotent). This protects against double application when the action is called twice.

3. **Optional: Tracking refresh action**  
   - Implement the optional “Refresh tracking” action described in Phase 2 (GET Shippo tracking by carrier + tracking number, update order and append one `tracking_update` event). Secure with admin-only check.

4. **Tests**  
   - Unit/integration tests for: (a) label creation — only `confirmed`, no duplicate label; (b) webhook handler — find order by tracking + carrier, set delivered on DELIVERED; (c) return/cancel only when confirmed; (d) refund idempotency (second call does not create a second Stripe refund, order updated once).

### 5.2 Acceptance (backend)

- Issuing refund twice for the same order does not create two Stripe refunds and does not double-restore stock or double-write timeline.  
- Refund flow is documented (full vs partial, idempotency rule).

---

## 6. Implementation order and dependencies

```
Phase 1 (Label creation)     → no dependency on Phase 2/3/4
Phase 2 (Track webhook)      → depends on orders having trackingNumber (from Phase 1 or manual)
Phase 3 (Return when confirmed) → independent; can run in parallel with 1 or 2
Phase 4 (Refund idempotency)  → depends on existing refund flow; can run after or in parallel with 3
```

Recommended sequence: **Phase 1 → Phase 2 → Phase 3 → Phase 4**, with Phase 3 and 4 swappable.

---

## 7. File and module checklist

| Area | File / module | Changes |
|------|----------------|---------|
| Schema | `convex/schema.ts` | Optional: `orders.labelUrl`; optional: `orders.trackingStatus` / `trackingStatusDetails`; index `by_tracking_number_and_carrier` on `["trackingNumber", "carrier"]` for webhook lookup. |
| Model | `convex/model/orders.ts` | `canCustomerRequestReturn`: allow only `confirmed`; keep `recordOrderTimelineEvent`. |
| Model | `convex/model/shippo.ts` | New: get shipment by ID; resolve rate by `shippingServiceCode` + `carrier`. |
| Orders | `convex/orders.ts` | Optional: internal mutation for “apply label data + status + timeline”; guards in `requestReturn` / cancel. |
| Actions | New or existing (e.g. `convex/fulfillmentActions.ts`) | Label flow: resolve rate → POST transaction → internal mutation; optional: refresh tracking action. |
| Refunds | `convex/returnActions.ts` | Idempotent refund: list refunds, then create or skip; call `applyRefund` only when order not already refunded. |
| Refunds | `convex/orders.ts` (`applyRefund`) | Idempotent: skip if already `paymentStatus === "refunded"`. |
| HTTP | `convex/http.ts` | New route `POST /shippo/webhook` → internal action. |
| Webhook handler | New or existing (e.g. `convex/shippoWebhook.ts`) | Parse Track Updated, find order, internal mutation to update order + insert `tracking_update` event; on DELIVERED set `delivered` and `actualDelivery`. |
| Tests | `convex/orders.test.ts`, `convex/fulfillmentActions.test.ts`, etc. | Label creation rules, webhook handler, return/cancel guards, refund idempotency. |

---

## 8. Environment and configuration

- **Convex:** No new env vars required for core flow.  
- **Shippo:** `SHIPPO_API_KEY` already used; ensure production webhook URL and (if applicable) webhook secret are configured for Track Updated.  
- **Stripe:** Existing keys; no new config for refunds.

---

## 9. Story-to-phase mapping

| Story | Phase | Backend work |
|-------|--------|----------------|
| US-ADM-1 Create shipping label | 1 | Resolve rate, POST transaction, persist label, status, timeline |
| US-ADM-2 Order status when label printed | 1 | Same mutation sets status to `processing` (or `shipped`) |
| US-ADM-3 View tracking (live via webhook) | 2 | Shippo webhook handler; optional refresh action |
| US-ADM-4 Return only when confirmed | 3 | `canCustomerRequestReturn` + guards in cancel/requestReturn |
| US-ADM-5 Refund after successful return | 4 | Idempotent Stripe refund + idempotent `applyRefund` |

---

*Document version: 1.0. No UI work included; UI can consume the new/updated queries and actions as needed.*
