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:
322
convex/orders.ts
322
convex/orders.ts
@@ -1,9 +1,10 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internal } from "./_generated/api";
|
||||
import * as Users from "./model/users";
|
||||
import { getOrderWithItems, validateCartItems, canCustomerCancel } from "./model/orders";
|
||||
import { getOrderWithItems, validateCartItems, canCustomerCancel, canCustomerRequestReturn, recordOrderTimelineEvent } from "./model/orders";
|
||||
import * as CartsModel from "./model/carts";
|
||||
|
||||
export const listMine = query({
|
||||
@@ -50,6 +51,21 @@ export const cancel = mutation({
|
||||
|
||||
await ctx.db.patch(id, { status: "cancelled", updatedAt: Date.now() });
|
||||
|
||||
await recordOrderTimelineEvent(ctx, {
|
||||
orderId: id,
|
||||
eventType: "customer_cancel",
|
||||
source: "customer_cancel",
|
||||
fromStatus: "confirmed",
|
||||
toStatus: "cancelled",
|
||||
userId: user._id,
|
||||
});
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.emails.sendCancellationNotice, {
|
||||
to: user.email,
|
||||
firstName: user.firstName ?? user.name.split(" ")[0] ?? "there",
|
||||
orderNumber: order.orderNumber,
|
||||
});
|
||||
|
||||
// Restore stock for each line item
|
||||
const items = await ctx.db
|
||||
.query("orderItems")
|
||||
@@ -366,6 +382,26 @@ export const createFromCart = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const getTimeline = query({
|
||||
args: { orderId: v.id("orders") },
|
||||
handler: async (ctx, { orderId }) => {
|
||||
const user = await Users.getCurrentUserOrThrow(ctx);
|
||||
const order = await ctx.db.get(orderId);
|
||||
if (!order) throw new Error("Order not found");
|
||||
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin";
|
||||
if (!isAdmin && order.userId !== user._id) {
|
||||
throw new Error("Unauthorized: order does not belong to you");
|
||||
}
|
||||
|
||||
return await ctx.db
|
||||
.query("orderTimelineEvents")
|
||||
.withIndex("by_order_and_created_at", (q) => q.eq("orderId", orderId))
|
||||
.order("asc")
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const updateStatus = mutation({
|
||||
args: {
|
||||
id: v.id("orders"),
|
||||
@@ -377,13 +413,24 @@ export const updateStatus = mutation({
|
||||
v.literal("delivered"),
|
||||
v.literal("cancelled"),
|
||||
v.literal("refunded"),
|
||||
v.literal("return"),
|
||||
v.literal("completed"),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, { id, status }) => {
|
||||
await Users.requireAdmin(ctx);
|
||||
const admin = await Users.requireAdmin(ctx);
|
||||
const order = await ctx.db.get(id);
|
||||
if (!order) throw new Error("Order not found");
|
||||
await ctx.db.patch(id, { status });
|
||||
const previousStatus = order.status;
|
||||
await ctx.db.patch(id, { status, updatedAt: Date.now() });
|
||||
await recordOrderTimelineEvent(ctx, {
|
||||
orderId: id,
|
||||
eventType: "status_change",
|
||||
source: "admin",
|
||||
fromStatus: previousStatus,
|
||||
toStatus: status,
|
||||
userId: admin._id,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
});
|
||||
@@ -542,6 +589,273 @@ export const fulfillFromCheckout = internalMutation({
|
||||
|
||||
await ctx.db.patch(cart._id, { items: [], updatedAt: now });
|
||||
|
||||
await recordOrderTimelineEvent(ctx, {
|
||||
orderId,
|
||||
eventType: "status_change",
|
||||
source: "stripe_webhook",
|
||||
toStatus: "confirmed",
|
||||
});
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.emails.sendOrderConfirmation, {
|
||||
to: user.email,
|
||||
firstName: user.firstName ?? user.name.split(" ")[0] ?? "there",
|
||||
orderNumber,
|
||||
total,
|
||||
currency: args.currency ?? "gbp",
|
||||
items: orderItems.map((i) => ({
|
||||
productName: i.productName,
|
||||
variantName: i.variantName,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
})),
|
||||
shippingAddress: {
|
||||
fullName: address.fullName,
|
||||
addressLine1: address.addressLine1,
|
||||
city: address.city,
|
||||
postalCode: address.postalCode,
|
||||
country: address.country,
|
||||
},
|
||||
});
|
||||
|
||||
return orderId;
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Return flow ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const requestReturn = mutation({
|
||||
args: { id: v.id("orders") },
|
||||
handler: async (ctx, { id }) => {
|
||||
const user = await Users.getCurrentUserOrThrow(ctx);
|
||||
|
||||
const order = await ctx.db.get(id);
|
||||
if (!order) throw new Error("Order not found");
|
||||
if (order.userId !== user._id)
|
||||
throw new Error("Unauthorized: order does not belong to you");
|
||||
|
||||
const { allowed, reason } = canCustomerRequestReturn(order);
|
||||
if (!allowed) throw new Error(reason);
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(id, { returnRequestedAt: now, updatedAt: now });
|
||||
|
||||
await recordOrderTimelineEvent(ctx, {
|
||||
orderId: id,
|
||||
eventType: "return_requested",
|
||||
source: "customer_return",
|
||||
userId: user._id,
|
||||
});
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.emails.sendReturnRequestedNotice, {
|
||||
to: user.email,
|
||||
firstName: user.firstName ?? user.name.split(" ")[0] ?? "there",
|
||||
orderNumber: order.orderNumber,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const markReturnReceived = mutation({
|
||||
args: { id: v.id("orders") },
|
||||
handler: async (ctx, { id }) => {
|
||||
const admin = await Users.requireAdmin(ctx);
|
||||
|
||||
const order = await ctx.db.get(id);
|
||||
if (!order) throw new Error("Order not found");
|
||||
if (!order.returnRequestedAt)
|
||||
throw new Error("No return has been requested for this order");
|
||||
if (order.returnReceivedAt)
|
||||
throw new Error("Return has already been marked as received");
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(id, { returnReceivedAt: now, status: "completed", updatedAt: now });
|
||||
|
||||
await recordOrderTimelineEvent(ctx, {
|
||||
orderId: id,
|
||||
eventType: "return_received",
|
||||
source: "admin",
|
||||
fromStatus: order.status,
|
||||
toStatus: "completed",
|
||||
userId: admin._id,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const getOrderForRefund = internalQuery({
|
||||
args: { id: v.id("orders") },
|
||||
handler: async (ctx, { id }) => {
|
||||
return await ctx.db.get(id);
|
||||
},
|
||||
});
|
||||
|
||||
export const getOrderByPaymentIntent = internalQuery({
|
||||
args: { stripePaymentIntentId: v.string() },
|
||||
handler: async (ctx, { stripePaymentIntentId }) => {
|
||||
return await ctx.db
|
||||
.query("orders")
|
||||
.withIndex("by_stripe_payment_intent_id", (q) =>
|
||||
q.eq("stripePaymentIntentId", stripePaymentIntentId),
|
||||
)
|
||||
.first();
|
||||
},
|
||||
});
|
||||
|
||||
export const applyReturnAccepted = internalMutation({
|
||||
args: {
|
||||
orderId: v.id("orders"),
|
||||
adminUserId: v.id("users"),
|
||||
returnLabelUrl: v.string(),
|
||||
returnTrackingNumber: v.string(),
|
||||
returnCarrier: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const order = await ctx.db.get(args.orderId);
|
||||
if (!order) throw new Error("Order not found");
|
||||
if (order.status !== "delivered")
|
||||
throw new Error("Order must be in delivered status to accept return.");
|
||||
if (!order.returnRequestedAt)
|
||||
throw new Error("No return has been requested for this order.");
|
||||
if (order.returnTrackingNumber)
|
||||
throw new Error("Return label has already been created for this order.");
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(args.orderId, {
|
||||
status: "processing",
|
||||
returnLabelUrl: args.returnLabelUrl,
|
||||
returnTrackingNumber: args.returnTrackingNumber,
|
||||
returnCarrier: args.returnCarrier,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await recordOrderTimelineEvent(ctx, {
|
||||
orderId: args.orderId,
|
||||
eventType: "return_accepted",
|
||||
source: "admin",
|
||||
fromStatus: "delivered",
|
||||
toStatus: "processing",
|
||||
userId: args.adminUserId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const applyLabel = internalMutation({
|
||||
args: {
|
||||
orderId: v.id("orders"),
|
||||
adminUserId: v.id("users"),
|
||||
trackingNumber: v.string(),
|
||||
trackingUrl: v.string(),
|
||||
labelUrl: v.optional(v.string()),
|
||||
estimatedDelivery: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const order = await ctx.db.get(args.orderId);
|
||||
if (!order) throw new Error("Order not found");
|
||||
if (order.status !== "confirmed") {
|
||||
throw new Error("Only confirmed orders can receive a shipping label.");
|
||||
}
|
||||
if (order.trackingNumber) {
|
||||
throw new Error(
|
||||
"A shipping label has already been created for this order.",
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(args.orderId, {
|
||||
trackingNumber: args.trackingNumber,
|
||||
trackingUrl: args.trackingUrl,
|
||||
labelUrl: args.labelUrl,
|
||||
estimatedDelivery: args.estimatedDelivery,
|
||||
shippedAt: now,
|
||||
status: "processing",
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await recordOrderTimelineEvent(ctx, {
|
||||
orderId: args.orderId,
|
||||
eventType: "label_created",
|
||||
source: "admin",
|
||||
fromStatus: "confirmed",
|
||||
toStatus: "processing",
|
||||
userId: args.adminUserId,
|
||||
payload: JSON.stringify({
|
||||
trackingNumber: args.trackingNumber,
|
||||
carrier: order.carrier,
|
||||
}),
|
||||
});
|
||||
|
||||
const customer = await ctx.db.get(order.userId);
|
||||
if (customer) {
|
||||
await ctx.scheduler.runAfter(0, internal.emails.sendShippingConfirmation, {
|
||||
to: customer.email,
|
||||
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
|
||||
orderNumber: order.orderNumber,
|
||||
trackingNumber: args.trackingNumber,
|
||||
trackingUrl: args.trackingUrl,
|
||||
carrier: order.carrier,
|
||||
estimatedDelivery: args.estimatedDelivery,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const applyRefund = internalMutation({
|
||||
args: {
|
||||
id: v.id("orders"),
|
||||
adminUserId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { id, adminUserId }) => {
|
||||
const order = await ctx.db.get(id);
|
||||
if (!order) throw new Error("Order not found");
|
||||
|
||||
// Idempotency guard: skip if already refunded
|
||||
if (order.paymentStatus === "refunded") {
|
||||
console.log(`[applyRefund] Order ${id} already refunded — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromStatus = order.status;
|
||||
await ctx.db.patch(id, {
|
||||
status: "refunded",
|
||||
paymentStatus: "refunded",
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
// Restore stock
|
||||
const items = await ctx.db
|
||||
.query("orderItems")
|
||||
.withIndex("by_order", (q) => q.eq("orderId", id))
|
||||
.collect();
|
||||
|
||||
for (const item of items) {
|
||||
const variant = await ctx.db.get(item.variantId);
|
||||
if (variant) {
|
||||
await ctx.db.patch(item.variantId, {
|
||||
stockQuantity: variant.stockQuantity + item.quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recordOrderTimelineEvent(ctx, {
|
||||
orderId: id,
|
||||
eventType: "refund",
|
||||
source: "admin",
|
||||
fromStatus,
|
||||
toStatus: "refunded",
|
||||
userId: adminUserId,
|
||||
});
|
||||
|
||||
const customer = await ctx.db.get(order.userId);
|
||||
if (customer) {
|
||||
await ctx.scheduler.runAfter(0, internal.emails.sendRefundNotice, {
|
||||
to: customer.email,
|
||||
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
|
||||
orderNumber: order.orderNumber,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user