Files
the-pet-loft/convex/fulfillmentActions.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

177 lines
5.0 KiB
TypeScript

import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import { getShipmentRateObjectId } from "./model/shippo";
const SHIPPO_TRANSACTIONS_URL = "https://api.goshippo.com/transactions/";
type LabelResult =
| { success: true; trackingNumber: string; trackingUrl: string }
| { success: false; code: string; message: string };
export const createShippingLabel = action({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }): Promise<LabelResult> => {
// 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
const order = await ctx.runQuery(internal.orders.getOrderForRefund, {
id: orderId,
});
if (!order) throw new Error("Order not found");
// 3. Validate — only confirmed orders without an existing label
if (order.status !== "confirmed") {
return {
success: false,
code: "INVALID_STATUS",
message: "Only confirmed orders can receive a shipping label.",
};
}
if (order.trackingNumber) {
return {
success: false,
code: "DUPLICATE_LABEL",
message: "A shipping label already exists for this order.",
};
}
if (!order.shippoShipmentId) {
return {
success: false,
code: "NO_SHIPMENT",
message: "Order has no Shippo shipment ID.",
};
}
// 4. 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 };
}
// 5. Purchase label via Shippo POST /transactions (synchronous)
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 }),
});
} catch {
return {
success: false,
code: "SHIPPO_UNREACHABLE",
message: "Could not reach Shippo to create the label.",
};
}
if (!txResponse.ok) {
let detail = "";
try {
detail = JSON.stringify(await txResponse.json());
} catch {}
console.error(
"Shippo /transactions/ 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;
eta?: 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 transaction failed:", tx.status, errMsg);
const isExpired = tx.messages?.some(
(m) =>
m.code === "carrier_account_invalid_credentials" ||
m.text.toLowerCase().includes("expired"),
);
return {
success: false,
code: isExpired ? "RATE_EXPIRED" : "SHIPPO_ERROR",
message: isExpired
? "The shipping rate has expired. Please try again."
: errMsg,
};
}
if (!tx.tracking_number) {
return {
success: false,
code: "NO_TRACKING",
message: "Shippo returned success but no tracking number.",
};
}
// 6. Persist label data and update order status
const etaMs =
tx.eta && !isNaN(new Date(tx.eta).getTime())
? new Date(tx.eta).getTime()
: undefined;
await ctx.runMutation(internal.orders.applyLabel, {
orderId,
adminUserId: userId,
trackingNumber: tx.tracking_number,
trackingUrl: tx.tracking_url_provider ?? "",
labelUrl: tx.label_url,
estimatedDelivery: etaMs,
});
return {
success: true,
trackingNumber: tx.tracking_number,
trackingUrl: tx.tracking_url_provider ?? "",
};
},
});