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, canCustomerRequestReturn, recordOrderTimelineEvent } from "./model/orders"; import * as CartsModel from "./model/carts"; export const listMine = query({ args: { paginationOpts: paginationOptsValidator, statusFilter: v.optional(v.array(v.string())), }, handler: async (ctx, args) => { const user = await Users.getCurrentUserOrThrow(ctx); const base = ctx.db .query("orders") .withIndex("by_user", (q) => q.eq("userId", user._id)) .order("desc"); if (args.statusFilter && args.statusFilter.length > 0) { const [first, ...rest] = args.statusFilter; return base .filter((q) => { const firstExpr = q.eq(q.field("status"), first as any); return rest.reduce( (acc: any, s) => q.or(acc, q.eq(q.field("status"), s as any)), firstExpr, ); }) .paginate(args.paginationOpts); } return base.paginate(args.paginationOpts); }, }); export const cancel = 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 } = canCustomerCancel(order); if (!allowed) throw new Error(reason); 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") .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, }); } } return { success: true }; }, }); export const getById = query({ args: { id: v.id("orders") }, handler: async (ctx, { id }) => { const user = await Users.getCurrentUserOrThrow(ctx); const order = await getOrderWithItems(ctx, id); 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 order; }, }); export const listAll = query({ args: { paginationOpts: paginationOptsValidator, status: v.optional(v.string()), paymentStatus: v.optional(v.string()), }, handler: async (ctx, args) => { await Users.requireAdmin(ctx); let q; if (args.status) { q = ctx.db .query("orders") .withIndex("by_status", (idx) => idx.eq("status", args.status as any)); } else if (args.paymentStatus) { q = ctx.db .query("orders") .withIndex("by_payment_status", (idx) => idx.eq("paymentStatus", args.paymentStatus as any), ); } else { q = ctx.db.query("orders"); } return await q.order("desc").paginate(args.paginationOpts); }, }); export const create = mutation({ args: { shippingAddressSnapshot: v.object({ fullName: v.string(), firstName: v.string(), lastName: v.string(), addressLine1: v.string(), additionalInformation: v.optional(v.string()), city: v.string(), postalCode: v.string(), country: v.string(), phone: v.optional(v.string()), }), billingAddressSnapshot: v.object({ firstName: v.string(), lastName: v.string(), addressLine1: v.string(), additionalInformation: v.optional(v.string()), city: v.string(), postalCode: v.string(), country: v.string(), }), items: v.array( v.object({ variantId: v.id("productVariants"), productName: v.string(), variantName: v.string(), sku: v.string(), quantity: v.number(), unitPrice: v.number(), imageUrl: v.optional(v.string()), }), ), shippingCost: v.number(), tax: v.optional(v.number()), discount: v.number(), currency: v.optional(v.string()), notes: v.optional(v.string()), shippingMethod: v.optional(v.string()), shippingServiceCode: v.optional(v.string()), carrier: v.optional(v.string()), shippoShipmentId: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await Users.getCurrentUserOrThrow(ctx); const subtotal = args.items.reduce( (sum, item) => sum + item.unitPrice * item.quantity, 0, ); const tax = args.tax ?? 0; const total = subtotal + args.shippingCost - args.discount + tax; const now = Date.now(); const orderNumber = `ORD-${Math.random().toString(36).substring(2, 7).toUpperCase()}`; const orderId = await ctx.db.insert("orders", { orderNumber, userId: user._id, email: user.email, status: "pending", paymentStatus: "pending", subtotal, tax, shipping: args.shippingCost, discount: args.discount, total, currency: args.currency ?? "USD", shippingAddressSnapshot: args.shippingAddressSnapshot, billingAddressSnapshot: args.billingAddressSnapshot, shippingMethod: args.shippingMethod ?? "", shippingServiceCode: args.shippingServiceCode ?? "", carrier: args.carrier ?? "", shippoShipmentId: args.shippoShipmentId ?? "", createdAt: now, updatedAt: now, notes: args.notes, }); for (const item of args.items) { await ctx.db.insert("orderItems", { orderId, variantId: item.variantId, productName: item.productName, variantName: item.variantName, sku: item.sku, quantity: item.quantity, unitPrice: item.unitPrice, totalPrice: item.unitPrice * item.quantity, imageUrl: item.imageUrl, }); } return orderId; }, }); const addressSnapshotValidator = { shippingAddressSnapshot: v.object({ fullName: v.string(), firstName: v.string(), lastName: v.string(), addressLine1: v.string(), additionalInformation: v.optional(v.string()), city: v.string(), postalCode: v.string(), country: v.string(), phone: v.optional(v.string()), }), billingAddressSnapshot: v.object({ firstName: v.string(), lastName: v.string(), addressLine1: v.string(), additionalInformation: v.optional(v.string()), city: v.string(), postalCode: v.string(), country: v.string(), }), }; /** * @deprecated Use `checkout.validateCart` instead — returns enriched items * with weight/dimension data and richer issue types (price drift, inactive variants). * * No frontend code references this query. It can be removed once * `createFromCart` is refactored to use `validateAndEnrichCart` from * `model/checkout.ts` (planned for the Stripe webhook fulfillment phase). */ export const validateCart = query({ args: { sessionId: v.optional(v.string()) }, handler: async (ctx, args) => { const user = await Users.getCurrentUser(ctx); const userId = user?._id; if (!userId && !args.sessionId) { return { valid: true, outOfStock: [] }; } const cart = await CartsModel.getCart(ctx, userId, args.sessionId); if (!cart || cart.items.length === 0) { return { valid: true, outOfStock: [] }; } const outOfStock = await validateCartItems(ctx, cart.items); return { valid: outOfStock.length === 0, outOfStock }; }, }); export const createFromCart = mutation({ args: { ...addressSnapshotValidator, shippingCost: v.number(), tax: v.optional(v.number()), discount: v.number(), currency: v.optional(v.string()), notes: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await Users.getCurrentUserOrThrow(ctx); const cart = await CartsModel.getCart(ctx, user._id); if (!cart || cart.items.length === 0) { throw new Error("Cart is empty"); } const outOfStock = await validateCartItems(ctx, cart.items); if (outOfStock.length > 0) { throw new Error("One or more items are out of stock"); } const orderItems: { variantId: import("./_generated/dataModel").Id<"productVariants">; productName: string; variantName: string; sku: string; quantity: number; unitPrice: number; imageUrl?: string; }[] = []; for (const item of cart.items) { if (!item.variantId) continue; const variant = await ctx.db.get(item.variantId); const product = await ctx.db.get(item.productId); if (!variant || !product) continue; const images = await ctx.db .query("productImages") .withIndex("by_product", (q) => q.eq("productId", item.productId)) .collect(); images.sort((a, b) => a.position - b.position); orderItems.push({ variantId: item.variantId, productName: product.name, variantName: variant.name, sku: variant.sku, quantity: item.quantity, unitPrice: item.price, imageUrl: images[0]?.url, }); } if (orderItems.length === 0) { throw new Error("Cart has no valid items"); } const subtotal = orderItems.reduce( (sum, item) => sum + item.unitPrice * item.quantity, 0 ); const tax = args.tax ?? 0; const total = subtotal + args.shippingCost - args.discount + tax; const now = Date.now(); const orderNumber = `ORD-${Math.random().toString(36).substring(2, 7).toUpperCase()}`; const orderId = await ctx.db.insert("orders", { orderNumber, userId: user._id, email: user.email, status: "pending", paymentStatus: "pending", subtotal, tax, shipping: args.shippingCost, discount: args.discount, total, currency: args.currency ?? "USD", shippingAddressSnapshot: args.shippingAddressSnapshot, billingAddressSnapshot: args.billingAddressSnapshot, shippoShipmentId: "", shippingMethod: "", shippingServiceCode: "", carrier: "", createdAt: now, updatedAt: now, notes: args.notes, }); for (const item of orderItems) { await ctx.db.insert("orderItems", { orderId, variantId: item.variantId, productName: item.productName, variantName: item.variantName, sku: item.sku, quantity: item.quantity, unitPrice: item.unitPrice, totalPrice: item.unitPrice * item.quantity, imageUrl: item.imageUrl, }); } await ctx.db.patch(cart._id, { items: [], updatedAt: now }); return orderId; }, }); 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"), status: v.union( v.literal("pending"), v.literal("confirmed"), v.literal("processing"), v.literal("shipped"), v.literal("delivered"), v.literal("cancelled"), v.literal("refunded"), v.literal("return"), v.literal("completed"), ), }, handler: async (ctx, { id, status }) => { const admin = await Users.requireAdmin(ctx); const order = await ctx.db.get(id); if (!order) throw new Error("Order not found"); 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; }, }); export const fulfillFromCheckout = internalMutation({ args: { stripeCheckoutSessionId: v.string(), stripePaymentIntentId: v.union(v.string(), v.null()), convexUserId: v.string(), addressId: v.string(), shipmentObjectId: v.string(), shippingMethod: v.string(), shippingServiceCode: v.string(), carrier: v.string(), amountTotal: v.union(v.number(), v.null()), amountShipping: v.number(), currency: v.union(v.string(), v.null()), }, handler: async (ctx, args) => { const existing = await ctx.db .query("orders") .withIndex("by_stripe_checkout_session_id", (q) => q.eq("stripeCheckoutSessionId", args.stripeCheckoutSessionId), ) .unique(); if (existing) { console.log( "Order already exists for session:", args.stripeCheckoutSessionId, ); return existing._id; } const userId = args.convexUserId as Id<"users">; const user = await ctx.db.get(userId); if (!user) throw new Error("User not found"); const addressId = args.addressId as Id<"addresses">; const address = await ctx.db.get(addressId); if (!address) throw new Error("Address not found"); const cart = await CartsModel.getCart(ctx, userId); if (!cart || cart.items.length === 0) throw new Error("Cart is empty"); const orderItems: Array<{ variantId: Id<"productVariants">; productName: string; variantName: string; sku: string; quantity: number; unitPrice: number; imageUrl: string | undefined; }> = []; for (const item of cart.items) { if (!item.variantId) continue; const variant = await ctx.db.get(item.variantId); const product = await ctx.db.get(item.productId); if (!variant || !product) continue; const images = await ctx.db .query("productImages") .withIndex("by_product", (q) => q.eq("productId", item.productId)) .collect(); images.sort((a, b) => a.position - b.position); orderItems.push({ variantId: item.variantId, productName: product.name, variantName: variant.name, sku: variant.sku, quantity: item.quantity, unitPrice: variant.price, imageUrl: images[0]?.url, }); } if (orderItems.length === 0) { throw new Error("Cart has no valid items"); } const subtotal = orderItems.reduce( (sum, item) => sum + item.unitPrice * item.quantity, 0, ); const shipping = args.amountShipping; const total = args.amountTotal ?? subtotal + shipping; const now = Date.now(); const orderNumber = `ORD-${Math.random().toString(36).substring(2, 7).toUpperCase()}`; const orderId = await ctx.db.insert("orders", { orderNumber, userId, email: user.email, status: "confirmed", paymentStatus: "paid", subtotal, tax: 0, shipping, discount: 0, total, currency: args.currency ?? "gbp", shippingAddressSnapshot: { fullName: address.fullName, firstName: address.firstName, lastName: address.lastName, addressLine1: address.addressLine1, additionalInformation: address.additionalInformation, city: address.city, postalCode: address.postalCode, country: address.country, phone: address.phone, }, billingAddressSnapshot: { firstName: address.firstName, lastName: address.lastName, addressLine1: address.addressLine1, additionalInformation: address.additionalInformation, city: address.city, postalCode: address.postalCode, country: address.country, }, stripeCheckoutSessionId: args.stripeCheckoutSessionId, stripePaymentIntentId: args.stripePaymentIntentId ?? undefined, shippoShipmentId: args.shipmentObjectId, shippingMethod: args.shippingMethod, shippingServiceCode: args.shippingServiceCode, carrier: args.carrier, createdAt: now, updatedAt: now, paidAt: now, }); for (const item of orderItems) { await ctx.db.insert("orderItems", { orderId, variantId: item.variantId, productName: item.productName, variantName: item.variantName, sku: item.sku, quantity: item.quantity, unitPrice: item.unitPrice, totalPrice: item.unitPrice * item.quantity, imageUrl: item.imageUrl, }); } for (const item of orderItems) { const variant = await ctx.db.get(item.variantId); if (variant) { await ctx.db.patch(item.variantId, { stockQuantity: variant.stockQuantity - item.quantity, }); } } 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, }); } }, });