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:
219
convex/returnActions.ts
Normal file
219
convex/returnActions.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
"use node";
|
||||
|
||||
import Stripe from "stripe";
|
||||
import { action } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { v } from "convex/values";
|
||||
import { getShipmentRateObjectId } from "./model/shippo";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
||||
|
||||
const SHIPPO_TRANSACTIONS_URL = "https://api.goshippo.com/transactions/";
|
||||
|
||||
type AcceptReturnResult =
|
||||
| { success: true; returnTrackingNumber: string }
|
||||
| { success: false; code: string; message: string };
|
||||
|
||||
export const acceptReturn = action({
|
||||
args: { orderId: v.id("orders") },
|
||||
handler: async (ctx, { orderId }): Promise<AcceptReturnResult> => {
|
||||
// 1. Auth — must be admin
|
||||
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
|
||||
const user = await ctx.runQuery(internal.users.getById, { userId });
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
throw new Error("Unauthorized: admin access required");
|
||||
}
|
||||
|
||||
// 2. Load order and validate
|
||||
const order = await ctx.runQuery(internal.orders.getOrderForRefund, {
|
||||
id: orderId,
|
||||
});
|
||||
if (!order) throw new Error("Order not found");
|
||||
if (order.status !== "delivered") {
|
||||
return {
|
||||
success: false,
|
||||
code: "INVALID_STATUS",
|
||||
message: "Only delivered orders can have a return accepted.",
|
||||
};
|
||||
}
|
||||
if (!order.returnRequestedAt) {
|
||||
return {
|
||||
success: false,
|
||||
code: "NO_RETURN_REQUEST",
|
||||
message: "No return has been requested for this order.",
|
||||
};
|
||||
}
|
||||
if (order.returnTrackingNumber) {
|
||||
return {
|
||||
success: false,
|
||||
code: "DUPLICATE_RETURN_LABEL",
|
||||
message: "A return label has already been created for this order.",
|
||||
};
|
||||
}
|
||||
if (!order.shippoShipmentId) {
|
||||
return {
|
||||
success: false,
|
||||
code: "NO_SHIPMENT",
|
||||
message: "Order has no Shippo shipment ID.",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Resolve rate object ID from the stored shipment
|
||||
let rateObjectId: string;
|
||||
try {
|
||||
rateObjectId = await getShipmentRateObjectId(
|
||||
order.shippoShipmentId,
|
||||
order.shippingServiceCode,
|
||||
order.carrier,
|
||||
);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to resolve shipping rate.";
|
||||
return { success: false, code: "RATE_ERROR", message };
|
||||
}
|
||||
|
||||
// 4. Purchase return label via Shippo POST /transactions (is_return: true)
|
||||
const apiKey = process.env.SHIPPO_API_KEY;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
code: "CONFIG_ERROR",
|
||||
message: "Shippo API key not configured.",
|
||||
};
|
||||
}
|
||||
|
||||
let txResponse: Response;
|
||||
try {
|
||||
txResponse = await fetch(SHIPPO_TRANSACTIONS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `ShippoToken ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ rate: rateObjectId, async: false, is_return: true }),
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
code: "SHIPPO_UNREACHABLE",
|
||||
message: "Could not reach Shippo to create the return label.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!txResponse.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
detail = JSON.stringify(await txResponse.json());
|
||||
} catch {}
|
||||
console.error("Shippo /transactions/ return error:", txResponse.status, detail);
|
||||
return {
|
||||
success: false,
|
||||
code: "SHIPPO_ERROR",
|
||||
message: `Shippo returned status ${txResponse.status}.`,
|
||||
};
|
||||
}
|
||||
|
||||
let tx: {
|
||||
object_id: string;
|
||||
status: string;
|
||||
tracking_number?: string;
|
||||
tracking_url_provider?: string;
|
||||
label_url?: string;
|
||||
messages?: Array<{ source: string; text: string; code: string }>;
|
||||
};
|
||||
try {
|
||||
tx = await txResponse.json();
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
code: "PARSE_ERROR",
|
||||
message: "Shippo response could not be parsed.",
|
||||
};
|
||||
}
|
||||
|
||||
if (tx.status !== "SUCCESS") {
|
||||
const errMsg =
|
||||
tx.messages?.map((m) => m.text).join("; ") ?? "Unknown Shippo error";
|
||||
console.error("Shippo return transaction failed:", tx.status, errMsg);
|
||||
return {
|
||||
success: false,
|
||||
code: "SHIPPO_ERROR",
|
||||
message: errMsg,
|
||||
};
|
||||
}
|
||||
|
||||
if (!tx.tracking_number || !tx.label_url) {
|
||||
return {
|
||||
success: false,
|
||||
code: "NO_TRACKING",
|
||||
message: "Shippo returned success but no tracking number or label URL.",
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Persist return label data
|
||||
await ctx.runMutation(internal.orders.applyReturnAccepted, {
|
||||
orderId,
|
||||
adminUserId: userId,
|
||||
returnLabelUrl: tx.label_url,
|
||||
returnTrackingNumber: tx.tracking_number,
|
||||
returnCarrier: order.carrier,
|
||||
});
|
||||
|
||||
// 6. Send return label email to customer
|
||||
const customer = await ctx.runQuery(internal.users.getById, {
|
||||
userId: order.userId,
|
||||
});
|
||||
await ctx.runMutation(internal.emails.sendReturnLabelEmail, {
|
||||
to: customer.email,
|
||||
firstName: customer.firstName ?? customer.name.split(" ")[0] ?? "there",
|
||||
orderNumber: order.orderNumber,
|
||||
returnLabelUrl: tx.label_url,
|
||||
});
|
||||
|
||||
return { success: true, returnTrackingNumber: tx.tracking_number };
|
||||
},
|
||||
});
|
||||
|
||||
export const issueRefund = action({
|
||||
args: { orderId: v.id("orders") },
|
||||
handler: async (ctx, { orderId }): Promise<{ success: boolean }> => {
|
||||
// 1. Auth — must be an admin
|
||||
const userId = await ctx.runQuery(internal.checkout.getCurrentUserId);
|
||||
const user = await ctx.runQuery(internal.users.getById, { userId });
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
throw new Error("Unauthorized: admin access required");
|
||||
}
|
||||
|
||||
// 2. Load order
|
||||
const order = await ctx.runQuery(internal.orders.getOrderForRefund, {
|
||||
id: orderId,
|
||||
});
|
||||
if (!order) throw new Error("Order not found");
|
||||
if (!order.stripePaymentIntentId)
|
||||
throw new Error("Order has no Stripe payment intent to refund");
|
||||
if (order.paymentStatus === "refunded")
|
||||
throw new Error("Order has already been refunded");
|
||||
|
||||
// 3. Create Stripe refund — idempotent: skip if one already exists
|
||||
const existingRefunds = await stripe.refunds.list({
|
||||
payment_intent: order.stripePaymentIntentId,
|
||||
limit: 10,
|
||||
});
|
||||
const hasRefund = existingRefunds.data.some(
|
||||
(r) => r.status === "succeeded" || r.status === "pending",
|
||||
);
|
||||
if (!hasRefund) {
|
||||
await stripe.refunds.create({
|
||||
payment_intent: order.stripePaymentIntentId,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Mark order refunded and restore stock — applyRefund is also idempotent
|
||||
await ctx.runMutation(internal.orders.applyRefund, {
|
||||
id: orderId,
|
||||
adminUserId: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user