feat(orders): implement QA audit fixes — return flow, refund webhook, TS cleanup
Convex backend (AUDIT-5–10): - schema: add returnLabelUrl, returnTrackingNumber, returnCarrier fields + by_return_tracking_number_and_carrier and by_stripe_payment_intent_id indexes - orders: markReturnReceived now sets status="completed"; add getOrderByPaymentIntent and applyReturnAccepted internal helpers - returnActions: add acceptReturn action — creates Shippo return label (is_return:true), persists label data, sends return label email to customer - stripeActions: handle refund.updated webhook to auto-mark orders refunded via Stripe Dashboard - shippoWebhook: add getOrderByReturnTracking; applyTrackingUpdate extended with isReturnTracking flag (return events use return_tracking_update type, skip delivered transition) - emails: add sendReturnLabelEmail; fulfillmentActions: createShippingLabel action Admin UI (AUDIT-1–6): - OrderActionsBar: full rewrite per authoritative action matrix; remove UpdateStatusDialog; add AcceptReturnButton for delivered+returnRequested state - AcceptReturnButton: new action component matching CreateLabelButton pattern - FulfilmentCard: add returnLabelUrl prop; show "Return label" row; rename outbound label to "Outbound label" when both are present - statusConfig: add return_accepted to OrderEventType and EVENT_TYPE_LABELS - orders detail page and all supporting cards/components Storefront & shared (TS fixes): - checkout/success, CheckoutErrorState, OrderReviewStep, PaymentStep: replace Button as/color/isLoading/variant="flat" with HeroUI v3-compatible props - ReviewList: isLoading → isPending for HeroUI v3 Button - packages/utils: add return and completed entries to ORDER_STATUS_LABELS/COLORS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
187
convex/shippoWebhook.ts
Normal file
187
convex/shippoWebhook.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
internalAction,
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
} from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { v } from "convex/values";
|
||||
import { recordOrderTimelineEvent } from "./model/orders";
|
||||
|
||||
// ─── Internal queries ─────────────────────────────────────────────────────────
|
||||
|
||||
export const getOrderByTracking = internalQuery({
|
||||
args: { trackingNumber: v.string(), carrier: v.string() },
|
||||
handler: async (ctx, { trackingNumber, carrier }) => {
|
||||
return await ctx.db
|
||||
.query("orders")
|
||||
.withIndex("by_tracking_number_and_carrier", (q) =>
|
||||
q.eq("trackingNumber", trackingNumber).eq("carrier", carrier),
|
||||
)
|
||||
.first();
|
||||
},
|
||||
});
|
||||
|
||||
export const getOrderByReturnTracking = internalQuery({
|
||||
args: { returnTrackingNumber: v.string(), returnCarrier: v.string() },
|
||||
handler: async (ctx, { returnTrackingNumber, returnCarrier }) => {
|
||||
return await ctx.db
|
||||
.query("orders")
|
||||
.withIndex("by_return_tracking_number_and_carrier", (q) =>
|
||||
q.eq("returnTrackingNumber", returnTrackingNumber).eq("returnCarrier", returnCarrier),
|
||||
)
|
||||
.first();
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Internal mutation ────────────────────────────────────────────────────────
|
||||
|
||||
export const applyTrackingUpdate = internalMutation({
|
||||
args: {
|
||||
orderId: v.id("orders"),
|
||||
trackingStatus: v.string(),
|
||||
estimatedDelivery: v.optional(v.number()),
|
||||
isDelivered: v.boolean(),
|
||||
isReturnTracking: v.boolean(),
|
||||
payload: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const order = await ctx.db.get(args.orderId);
|
||||
if (!order) return;
|
||||
|
||||
// Idempotency: skip if this exact status was already applied
|
||||
if (order.trackingStatus === args.trackingStatus) {
|
||||
console.log(
|
||||
`[shippoWebhook] Skipping duplicate tracking status "${args.trackingStatus}" for order ${args.orderId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Return tracking updates never set status to "delivered" — only outbound does
|
||||
if (args.isDelivered && !args.isReturnTracking) {
|
||||
await ctx.db.patch(args.orderId, {
|
||||
trackingStatus: args.trackingStatus,
|
||||
...(args.estimatedDelivery !== undefined
|
||||
? { estimatedDelivery: args.estimatedDelivery }
|
||||
: {}),
|
||||
status: "delivered",
|
||||
actualDelivery: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
await ctx.db.patch(args.orderId, {
|
||||
trackingStatus: args.trackingStatus,
|
||||
...(args.estimatedDelivery !== undefined
|
||||
? { estimatedDelivery: args.estimatedDelivery }
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await recordOrderTimelineEvent(ctx, {
|
||||
orderId: args.orderId,
|
||||
eventType: args.isReturnTracking ? "return_tracking_update" : "tracking_update",
|
||||
source: "shippo_webhook",
|
||||
...(args.isDelivered && !args.isReturnTracking
|
||||
? { fromStatus: order.status, toStatus: "delivered" }
|
||||
: {}),
|
||||
payload: args.payload,
|
||||
});
|
||||
|
||||
if (args.isDelivered && !args.isReturnTracking) {
|
||||
const customer = await ctx.db.get(order.userId);
|
||||
if (customer) {
|
||||
await ctx.scheduler.runAfter(0, internal.emails.sendDeliveryConfirmation, {
|
||||
to: customer.email,
|
||||
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
|
||||
orderNumber: order.orderNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Internal action ──────────────────────────────────────────────────────────
|
||||
|
||||
type ShippoTrackUpdatedPayload = {
|
||||
event?: string;
|
||||
data?: {
|
||||
carrier?: string;
|
||||
tracking_number?: string;
|
||||
eta?: string | null;
|
||||
tracking_status?: {
|
||||
status?: string;
|
||||
status_details?: string;
|
||||
status_date?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const handleTrackUpdated = internalAction({
|
||||
args: { body: v.string() },
|
||||
handler: async (ctx, { body }) => {
|
||||
let payload: ShippoTrackUpdatedPayload;
|
||||
try {
|
||||
payload = JSON.parse(body) as ShippoTrackUpdatedPayload;
|
||||
} catch {
|
||||
console.error("[shippoWebhook] Failed to parse JSON body");
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.event !== "track_updated") {
|
||||
console.log("[shippoWebhook] Ignoring event:", payload.event);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = payload;
|
||||
const trackingNumber = data?.tracking_number;
|
||||
const carrier = data?.carrier;
|
||||
|
||||
if (!trackingNumber || !carrier) {
|
||||
console.error(
|
||||
"[shippoWebhook] Missing tracking_number or carrier in payload",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let order = await ctx.runQuery(
|
||||
internal.shippoWebhook.getOrderByTracking,
|
||||
{ trackingNumber, carrier },
|
||||
);
|
||||
let isReturnTracking = false;
|
||||
|
||||
if (!order) {
|
||||
order = await ctx.runQuery(
|
||||
internal.shippoWebhook.getOrderByReturnTracking,
|
||||
{ returnTrackingNumber: trackingNumber, returnCarrier: carrier },
|
||||
);
|
||||
isReturnTracking = !!order;
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
console.log(
|
||||
`[shippoWebhook] No order found for tracking ${trackingNumber} / ${carrier}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const trackingStatus = data?.tracking_status?.status ?? "UNKNOWN";
|
||||
const isDelivered = trackingStatus === "DELIVERED";
|
||||
|
||||
const eta = data?.eta;
|
||||
const estimatedDelivery =
|
||||
eta && !isNaN(new Date(eta).getTime())
|
||||
? new Date(eta).getTime()
|
||||
: undefined;
|
||||
|
||||
await ctx.runMutation(internal.shippoWebhook.applyTrackingUpdate, {
|
||||
orderId: order._id,
|
||||
trackingStatus,
|
||||
estimatedDelivery,
|
||||
isDelivered,
|
||||
isReturnTracking,
|
||||
payload: body,
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user