# 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://.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.*