Files
the-pet-loft/convex/shippoWebhook.ts
ianshaloom 3d50cb895c 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>
2026-03-07 17:59:29 +03:00

188 lines
5.8 KiB
TypeScript

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,
});
},
});