import { query, mutation, internalMutation } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import * as Users from "./model/users"; import { getOrderWithItems, validateCartItems, canCustomerCancel } 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() }); // 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 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"), ), }, handler: async (ctx, { id, status }) => { 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 }); 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 }); return orderId; }, });