";
+
+// ─── HTML helpers ─────────────────────────────────────────────────────────────
+
+function base(body: string): string {
+ return `
+
+
+
The Pet Loft
+
+
+ ${body}
+
+
+ © ${new Date().getFullYear()} The Pet Loft. All rights reserved.
+
+
+ `;
+}
+
+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 `${label}`;
+}
+
+// ─── 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) => `
+
+
+ ${item.productName}
+ ${item.variantName}
+ |
+ ×${item.quantity} |
+
+ ${formatPrice(item.unitPrice * item.quantity, args.currency)}
+ |
+
`,
+ )
+ .join("");
+
+ const addr = args.shippingAddress;
+
+ const html = base(`
+ Order confirmed!
+ Hi ${args.firstName}, thank you for your order. We’re getting it ready now.
+ Order: ${args.orderNumber}
+
+
+ ${rows}
+
+ | Total |
+
+ ${formatPrice(args.total, args.currency)}
+ |
+
+
+
+ Shipping to:
+
+ ${addr.fullName}
+ ${addr.addressLine1}
+ ${addr.city}, ${addr.postalCode}
+ ${addr.country}
+
+ `);
+
+ 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
+ ? `Estimated delivery: ${new Date(args.estimatedDelivery).toDateString()}
`
+ : "";
+
+ const html = base(`
+ Your order is on its way!
+ Hi ${args.firstName}, ${args.orderNumber} has been shipped.
+ Carrier: ${args.carrier}
+ Tracking number: ${args.trackingNumber}
+ ${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(`
+ Your order has been delivered!
+ Hi ${args.firstName}, your order ${args.orderNumber} has been delivered.
+ We hope your pets love their new goodies! If anything is wrong with your order, please contact our support team.
+ `);
+
+ 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(`
+ Order cancelled
+ Hi ${args.firstName}, your order ${args.orderNumber} has been cancelled.
+ If you did not request this cancellation or need help, please get in touch with our support team.
+ `);
+
+ 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(`
+ Refund processed
+ Hi ${args.firstName}, your refund for order ${args.orderNumber} has been processed.
+ Refund amount: ${formatPrice(args.total, args.currency)}
+ Please allow 5–10 business days for the amount to appear on your statement.
+ `);
+
+ 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(`
+ Your return label is ready
+ Hi ${args.firstName}, your return request for order ${args.orderNumber} has been accepted.
+ Please use the link below to download your prepaid return label and attach it to your parcel.
+ ${btn(args.returnLabelUrl, "Download return label")}
+ Once we receive your return, we’ll process your refund promptly.
+ `);
+
+ 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(`
+ Return request received
+ Hi ${args.firstName}, we’ve received your return request for order ${args.orderNumber}.
+ Our team will review it and get back to you within 2 business days.
+ `);
+
+ await resend.sendEmail(ctx, {
+ from: FROM,
+ to: args.to,
+ subject: `Return request received for order ${args.orderNumber}`,
+ html,
+ });
+ },
+});
diff --git a/convex/fulfillmentActions.ts b/convex/fulfillmentActions.ts
new file mode 100644
index 0000000..c85be1c
--- /dev/null
+++ b/convex/fulfillmentActions.ts
@@ -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 => {
+ // 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 ?? "",
+ };
+ },
+});
diff --git a/convex/orders.ts b/convex/orders.ts
index 8c1629f..1023310 100644
--- a/convex/orders.ts
+++ b/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,
+ });
+ }
+ },
+});
diff --git a/convex/returnActions.ts b/convex/returnActions.ts
new file mode 100644
index 0000000..29fe982
--- /dev/null
+++ b/convex/returnActions.ts
@@ -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 => {
+ // 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 };
+ },
+});
diff --git a/convex/schema.ts b/convex/schema.ts
index 45baede..244ea60 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -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"),
diff --git a/convex/shippoWebhook.ts b/convex/shippoWebhook.ts
new file mode 100644
index 0000000..e2d85f3
--- /dev/null
+++ b/convex/shippoWebhook.ts
@@ -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,
+ });
+ },
+});
diff --git a/convex/stripeActions.ts b/convex/stripeActions.ts
index d79052a..a1e5845 100644
--- a/convex/stripeActions.ts
+++ b/convex/stripeActions.ts
@@ -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);
}
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index b9847d8..f09a06c 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -122,6 +122,8 @@ export const ORDER_STATUS_LABELS: Record = {
delivered: "Delivered",
cancelled: "Cancelled",
refunded: "Refunded",
+ return: "Return Requested",
+ completed: "Completed",
};
export const PAYMENT_STATUS_LABELS: Record = {
@@ -139,6 +141,8 @@ export const ORDER_STATUS_COLORS: Record = {
delivered: "green",
cancelled: "red",
refunded: "gray",
+ return: "orange",
+ completed: "teal",
};
// ─── Validation ───────────────────────────────────────────────────────────────