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:
2026-03-07 17:59:29 +03:00
parent 8e4309892c
commit 3d50cb895c
32 changed files with 3046 additions and 45 deletions

283
convex/emails.ts Normal file
View File

@@ -0,0 +1,283 @@
import { components } from "./_generated/api";
import { Resend } from "@convex-dev/resend";
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
// ─── Component instance ───────────────────────────────────────────────────────
export const resend = new Resend(components.resend, {
// Set testMode: false once you have a verified Resend domain and want to
// deliver to real addresses. While testMode is true, only Resend's own test
// addresses (e.g. delivered@resend.dev) will actually receive mail.
testMode: false,
});
// Update this once your sending domain is verified in Resend.
const FROM = "The Pet Loft <no-reply@thepetloft.co.uk>";
// ─── HTML helpers ─────────────────────────────────────────────────────────────
function base(body: string): string {
return `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;background:#f0f8f7;padding:24px">
<div style="background:#236f6b;padding:20px 24px;border-radius:8px 8px 0 0">
<h1 style="color:#fff;margin:0;font-size:22px;letter-spacing:-0.5px">The Pet Loft</h1>
</div>
<div style="background:#fff;padding:32px 24px;border-radius:0 0 8px 8px;color:#1a2e2d">
${body}
</div>
<p style="color:#1a2e2d;font-size:12px;text-align:center;margin-top:16px;opacity:0.6">
&copy; ${new Date().getFullYear()} The Pet Loft. All rights reserved.
</p>
</div>
`;
}
function formatPrice(amountInSmallestUnit: number, currency: string): string {
return new Intl.NumberFormat("en-GB", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amountInSmallestUnit / 100);
}
function btn(href: string, label: string): string {
return `<a href="${href}" style="display:inline-block;background:#38a99f;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold;margin-top:16px">${label}</a>`;
}
// ─── Order confirmation ───────────────────────────────────────────────────────
export const sendOrderConfirmation = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
total: v.number(),
currency: v.string(),
items: v.array(
v.object({
productName: v.string(),
variantName: v.string(),
quantity: v.number(),
unitPrice: v.number(),
}),
),
shippingAddress: v.object({
fullName: v.string(),
addressLine1: v.string(),
city: v.string(),
postalCode: v.string(),
country: v.string(),
}),
},
handler: async (ctx, args) => {
const rows = args.items
.map(
(item) => `
<tr>
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7">
<strong>${item.productName}</strong><br>
<span style="font-size:13px;opacity:0.7">${item.variantName}</span>
</td>
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7;text-align:center">×${item.quantity}</td>
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7;text-align:right;color:#236f6b;font-weight:bold">
${formatPrice(item.unitPrice * item.quantity, args.currency)}
</td>
</tr>`,
)
.join("");
const addr = args.shippingAddress;
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Order confirmed!</h2>
<p>Hi ${args.firstName}, thank you for your order. We&rsquo;re getting it ready now.</p>
<p style="margin-bottom:4px"><strong>Order:</strong> ${args.orderNumber}</p>
<table style="width:100%;border-collapse:collapse;margin:20px 0">
${rows}
<tr>
<td colspan="2" style="padding:12px 0;font-weight:bold">Total</td>
<td style="padding:12px 0;text-align:right;color:#236f6b;font-weight:bold;font-size:18px">
${formatPrice(args.total, args.currency)}
</td>
</tr>
</table>
<p style="margin-bottom:4px"><strong>Shipping to:</strong></p>
<p style="margin:0;line-height:1.6">
${addr.fullName}<br>
${addr.addressLine1}<br>
${addr.city}, ${addr.postalCode}<br>
${addr.country}
</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Order confirmed — ${args.orderNumber}`,
html,
});
},
});
// ─── Shipping confirmation ────────────────────────────────────────────────────
export const sendShippingConfirmation = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
trackingNumber: v.string(),
trackingUrl: v.string(),
carrier: v.string(),
estimatedDelivery: v.optional(v.number()),
},
handler: async (ctx, args) => {
const eta = args.estimatedDelivery
? `<p><strong>Estimated delivery:</strong> ${new Date(args.estimatedDelivery).toDateString()}</p>`
: "";
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Your order is on its way!</h2>
<p>Hi ${args.firstName}, <strong>${args.orderNumber}</strong> has been shipped.</p>
<p><strong>Carrier:</strong> ${args.carrier}</p>
<p><strong>Tracking number:</strong> ${args.trackingNumber}</p>
${eta}
${btn(args.trackingUrl, "Track your order")}
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Your order ${args.orderNumber} has shipped`,
html,
});
},
});
// ─── Delivery confirmation ────────────────────────────────────────────────────
export const sendDeliveryConfirmation = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Your order has been delivered!</h2>
<p>Hi ${args.firstName}, your order <strong>${args.orderNumber}</strong> has been delivered.</p>
<p>We hope your pets love their new goodies! If anything is wrong with your order, please contact our support team.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Your order ${args.orderNumber} has been delivered`,
html,
});
},
});
// ─── Cancellation ─────────────────────────────────────────────────────────────
export const sendCancellationNotice = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#f2705a;margin-top:0">Order cancelled</h2>
<p>Hi ${args.firstName}, your order <strong>${args.orderNumber}</strong> has been cancelled.</p>
<p>If you did not request this cancellation or need help, please get in touch with our support team.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Your order ${args.orderNumber} has been cancelled`,
html,
});
},
});
// ─── Refund ───────────────────────────────────────────────────────────────────
export const sendRefundNotice = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
total: v.number(),
currency: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Refund processed</h2>
<p>Hi ${args.firstName}, your refund for order <strong>${args.orderNumber}</strong> has been processed.</p>
<p><strong>Refund amount:</strong> ${formatPrice(args.total, args.currency)}</p>
<p>Please allow 510 business days for the amount to appear on your statement.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Refund processed for order ${args.orderNumber}`,
html,
});
},
});
// ─── Return label ─────────────────────────────────────────────────────────────
export const sendReturnLabelEmail = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
returnLabelUrl: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Your return label is ready</h2>
<p>Hi ${args.firstName}, your return request for order <strong>${args.orderNumber}</strong> has been accepted.</p>
<p>Please use the link below to download your prepaid return label and attach it to your parcel.</p>
${btn(args.returnLabelUrl, "Download return label")}
<p style="margin-top:16px;font-size:13px;color:#888">Once we receive your return, we&rsquo;ll process your refund promptly.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Return label for order ${args.orderNumber}`,
html,
});
},
});
// ─── Return requested ─────────────────────────────────────────────────────────
export const sendReturnRequestedNotice = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Return request received</h2>
<p>Hi ${args.firstName}, we&rsquo;ve received your return request for order <strong>${args.orderNumber}</strong>.</p>
<p>Our team will review it and get back to you within 2 business days.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Return request received for order ${args.orderNumber}`,
html,
});
},
});

View File

@@ -0,0 +1,176 @@
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 ?? "",
};
},
});

View File

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

219
convex/returnActions.ts Normal file
View 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 };
},
});

View File

@@ -167,6 +167,8 @@ export default defineSchema({
v.literal("delivered"),
v.literal("cancelled"),
v.literal("refunded"),
v.literal("return"),
v.literal("completed"),
),
paymentStatus: v.union(
v.literal("pending"),
@@ -209,6 +211,8 @@ export default defineSchema({
carrier: v.string(),
trackingNumber: v.optional(v.string()),
trackingUrl: v.optional(v.string()),
labelUrl: v.optional(v.string()),
trackingStatus: v.optional(v.string()),
estimatedDelivery: v.optional(v.number()),
actualDelivery: v.optional(v.number()),
notes: v.optional(v.string()),
@@ -216,6 +220,11 @@ export default defineSchema({
updatedAt: v.number(),
paidAt: v.optional(v.number()),
shippedAt: v.optional(v.number()),
returnRequestedAt: v.optional(v.number()),
returnReceivedAt: v.optional(v.number()),
returnLabelUrl: v.optional(v.string()),
returnTrackingNumber: v.optional(v.string()),
returnCarrier: v.optional(v.string()),
})
.index("by_user", ["userId"])
.index("by_status", ["status"])
@@ -223,7 +232,10 @@ export default defineSchema({
.index("by_order_number", ["orderNumber"])
.index("by_email", ["email"])
.index("by_created_at", ["createdAt"])
.index("by_stripe_checkout_session_id", ["stripeCheckoutSessionId"]),
.index("by_stripe_checkout_session_id", ["stripeCheckoutSessionId"])
.index("by_tracking_number_and_carrier", ["trackingNumber", "carrier"])
.index("by_return_tracking_number_and_carrier", ["returnTrackingNumber", "returnCarrier"])
.index("by_stripe_payment_intent_id", ["stripePaymentIntentId"]),
orderItems: defineTable({
orderId: v.id("orders"),
@@ -237,6 +249,19 @@ export default defineSchema({
imageUrl: v.optional(v.string()),
}).index("by_order", ["orderId"]),
orderTimelineEvents: defineTable({
orderId: v.id("orders"),
eventType: v.string(), // "status_change" | "customer_cancel" | "return_requested" | "return_received" | "refund" | "tracking_update" | "label_created"
source: v.string(), // "stripe_webhook" | "fulfillment" | "admin" | "shippo_webhook" | "customer_cancel" | "customer_return"
fromStatus: v.optional(v.string()),
toStatus: v.optional(v.string()),
payload: v.optional(v.string()), // JSON string for Shippo/Stripe payloads
createdAt: v.number(),
userId: v.optional(v.id("users")),
})
.index("by_order", ["orderId"])
.index("by_order_and_created_at", ["orderId", "createdAt"]),
// ─── Reviews ───────────────────────────────────────────────────────────
reviews: defineTable({
productId: v.id("products"),

187
convex/shippoWebhook.ts Normal file
View 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,
});
},
});

View File

@@ -229,6 +229,26 @@ export const handleWebhook = internalAction({
(event.data.object as Stripe.Checkout.Session).id,
);
break;
case "refund.updated": {
const refund = event.data.object as Stripe.Refund;
if (refund.status === "succeeded" && refund.payment_intent) {
const paymentIntentId =
typeof refund.payment_intent === "string"
? refund.payment_intent
: refund.payment_intent.id;
const order = await ctx.runQuery(
internal.orders.getOrderByPaymentIntent,
{ stripePaymentIntentId: paymentIntentId },
);
if (order && order.paymentStatus !== "refunded") {
await ctx.runMutation(internal.orders.applyRefund, {
id: order._id,
adminUserId: order.userId,
});
}
}
break;
}
default:
console.log("Unhandled Stripe event type:", event.type);
}