Files
the-pet-loft/apps/admin/docs/06-order-processing-backend-implementation-plan.md
ianshaloom 83a5172397 feat(admin): update configuration and documentation for order processing
- 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.
2026-03-07 19:31:10 +03:00

17 KiB
Raw Blame History

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 (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 rates 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 (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), 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, 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.