- Removed .env.example file as it is no longer needed. - Updated next.config.js to include turbopack configuration. - Added detailed implementation plans for order processing and UI design in new markdown files. - Introduced API usage guide for background removal service. This commit enhances the order processing backend and UI design documentation, ensuring clarity and improved configuration management.
17 KiB
Order Processing Backend — Phased Implementation Plan
Audience: Senior software engineers
Scope: Backend only (Convex, Stripe, Shippo). No UI.
Source: Admin Order Processing User Stories.
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(includingshippoShipmentId,shippingServiceCode,carrier,trackingNumber,trackingUrl,estimatedDelivery,actualDelivery,shippedAt,returnRequestedAt,returnReceivedAt);orderTimelineEventswithby_orderandby_order_and_created_at. - Model:
recordOrderTimelineEventinconvex/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
-
Schema (optional)
- Add
labelUrl: v.optional(v.string())toordersif the team wants to store the label PDF/PNG URL from Shippo. - If not stored, admin can open
tracking_url_provideror use Shippo dashboard for the label.
- Add
-
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 MCPshipments-getfrom an action). - From the response
ratesarray, find the rate whererate.servicelevel.token === order.shippingServiceCodeandrate.provider === order.carrier. - Return that rate’s
object_id.
- Add a function that calls Shippo GET
- Handle: shipment not found, no matching rate, or multiple matches (pick first or fail explicitly).
- In
-
Shippo: Create transaction (purchase label)
- Add a Convex action (e.g. in
convex/fulfillmentActions.tsorconvex/orders.tswith"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 notrackingNumberyet (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
/transactionswith{ rate: rateObjectId }(andasync: truefor international if applicable). - If Shippo returns async (e.g. status
QUEUED), poll GET transaction untilSUCCESSorERROR(or schedule a follow-up action to poll). - On success: extract
tracking_number,tracking_url_provider, and optionallylabel_urlfrom the transaction response.
- Takes
- Add a Convex action (e.g. in
-
Persist label and update order
- From the same action, call an internal mutation that:
- Receives
orderId,trackingNumber,trackingUrl(fromtracking_url_provider), optionallabelUrl, and optionaletaif Shippo provides it. - Validates again that order is
confirmedand has no existingtrackingNumber(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
recordOrderTimelineEventwitheventType: "label_created",source: "admin",fromStatus: "confirmed",toStatus: "processing"(or"shipped"), and optionalpayload(e.g. JSON with tracking number and carrier).
- Receives
- 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.
- From the same action, call an internal mutation that:
-
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
confirmedand without an existingtrackingNumbercan receive a label. - After a successful label creation, order has
trackingNumber,trackingUrl,shippedAt, and statusprocessing(orshipped); onelabel_createdtimeline 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 (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
-
HTTP route
- In
convex/http.ts, add a route, e.g.POST /shippo/webhook. - Handler:
httpActionthat reads body, optionally verifies Shippo signature (if Shippo supports it; check Shippo webhooks), then calls an internal action with the raw body (or parsed JSON).
- In
-
Webhook handler action
- Implement an internal action (e.g.
internal.fulfillmentActions.handleShippoTrackUpdatedorinternal.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 onorderse.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/trackingStatusDetailsonordersif desired for display; otherwise derive from timeline). - Sets
estimatedDeliveryfrom webhooketaif provided. - If tracking status is DELIVERED: set
order.status = "delivered",actualDelivery = Date.now(),updatedAt = Date.now(). - Inserts one
orderTimelineEventsrow:eventType: "tracking_update",source: "shippo_webhook",payload: JSON.stringify(webhookPayload),createdAt: Date.now().
- Updates the order: set latest tracking state (e.g. store a minimal
- Implement an internal action (e.g.
-
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.
- Shippo may send duplicate events. Options: (a) store last processed
-
Shippo webhook registration
- Document or script: register in Shippo dashboard (or via Shippo MCP
webhooks-create) a webhook withevent: "track_updated"and URLhttps://<deployment>.convex.site/shippo/webhook. Use production URL and live API key for live tracking.
- Document or script: register in Shippo dashboard (or via Shippo MCP
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 MCPtracking-status-get) and then updates the order + appends atracking_updatetimeline event from the response. Use for debugging or one-off backfills; primary source remains the webhook.
3.3 Acceptance (backend)
- POST to
/shippo/webhookwith a valid Track Updated payload updates the correct order and appends atracking_updateevent. - When status is DELIVERED, order
statusbecomesdeliveredandactualDeliveryis set. - Unknown tracking numbers or missing orders do not cause 5xx (return 200 and log).
3.4 Dependencies and references
- Shippo: Track live shipments, webhook payload shape.
- Shippo MCP:
webhooks-create(eventtrack_updated, url = Convex HTTP URL);tracking-status-getfor 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
-
Cancel (already aligned)
orders.cancelalready usescanCustomerCancel(order)which allows onlyconfirmed. No change required unless you want to explicitly document or add a test that onlyconfirmedis allowed.
-
Return request
- Today
canCustomerRequestReturnallows return only whenorder.status === "delivered". Per user stories, return is only allowed while status is confirmed. - Update
canCustomerRequestReturninconvex/model/orders.tsso that it returnsallowed: trueonly whenorder.status === "confirmed"(and optionally not already cancelled/refunded/return requested). - Adjust
orders.requestReturnso 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
requestReturnfor a distinct “return requested” state that stays inconfirmeduntil admin processes it.
- Today
-
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 ofcancelandrequestReturn(e.g. “only confirmed orders can be cancelled/returned”) so the contract is clear.
- Ensure any customer-facing mutation that cancels or requests a return checks the same rule:
-
Timeline
- When a return is requested (while confirmed), keep recording the existing
return_requested(or equivalent) timeline event withsource: "customer_return".
- When a return is requested (while confirmed), keep recording the existing
4.2 Acceptance (backend)
- Customer cancel and customer return request succeed only when
order.status === "confirmed". - For
processing,shipped, ordelivered, 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
-
Idempotent Stripe refund
- In
returnActions.issueRefund(or equivalent): before callingstripe.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.applyRefundonly if the order is not yetrefunded(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.
- In
-
Order guard in applyRefund
- In
orders.applyRefund, at the start: iforder.paymentStatus === "refunded"(and optionallyorder.status === "refunded"), skip patching and timeline (idempotent). This protects against double application when the action is called twice.
- In
-
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_updateevent). Secure with admin-only check.
- Implement the optional “Refresh tracking” action described in Phase 2 (GET Shippo tracking by carrier + tracking number, update order and append one
-
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).
- Unit/integration tests for: (a) label creation — only
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_KEYalready 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.