import { NextRequest, NextResponse } from "next/server"; import { orderNotificationService } from "@/lib/services/OrderNotificationService"; import { analyticsService } from "@/lib/services/AnalyticsService"; // Saleor webhook payload interfaces (snake_case) interface SaleorLineItemPayload { id: string; product_name: string; variant_name?: string; quantity: number; total_price_gross_amount: string; currency: string; } interface SaleorAddressPayload { first_name?: string; last_name?: string; street_address_1?: string; street_address_2?: string; city?: string; postal_code?: string; country?: string; phone?: string; } interface SaleorOrderPayload { id: string; number: number; user_email: string; first_name?: string; last_name?: string; billing_address?: SaleorAddressPayload; shipping_address?: SaleorAddressPayload; lines: SaleorLineItemPayload[]; total_gross_amount: string; shipping_price_gross_amount?: string; channel: { currency_code: string }; currency?: string; language_code?: string; metadata?: Record; } // Internal camelCase interfaces interface OrderItem { id: string; productName: string; variantName?: string; quantity: number; totalPrice: { gross: { amount: number; currency: string }; }; } interface OrderAddress { firstName?: string; lastName?: string; streetAddress1?: string; streetAddress2?: string; city?: string; postalCode?: string; country?: string; phone?: string; } interface Order { id: string; number: string; userEmail: string; user?: { firstName?: string; lastName?: string }; billingAddress?: OrderAddress; shippingAddress?: OrderAddress; lines: OrderItem[]; total: { gross: { amount: number; currency: string } }; languageCode?: string; metadata?: Array<{ key: string; value: string }>; } const SUPPORTED_EVENTS = [ "ORDER_CREATED", "ORDER_CONFIRMED", "ORDER_FULLY_PAID", "ORDER_CANCELLED", "ORDER_FULFILLED", ]; // Convert Saleor payload to internal format function convertPayloadToOrder(payload: SaleorOrderPayload): Order { return { id: payload.id, number: String(payload.number), userEmail: payload.user_email, user: payload.first_name || payload.last_name ? { firstName: payload.first_name, lastName: payload.last_name, } : undefined, billingAddress: payload.billing_address ? { firstName: payload.billing_address.first_name, lastName: payload.billing_address.last_name, streetAddress1: payload.billing_address.street_address_1, streetAddress2: payload.billing_address.street_address_2, city: payload.billing_address.city, postalCode: payload.billing_address.postal_code, country: payload.billing_address.country, phone: payload.billing_address.phone, } : undefined, shippingAddress: payload.shipping_address ? { firstName: payload.shipping_address.first_name, lastName: payload.shipping_address.last_name, streetAddress1: payload.shipping_address.street_address_1, streetAddress2: payload.shipping_address.street_address_2, city: payload.shipping_address.city, postalCode: payload.shipping_address.postal_code, country: payload.shipping_address.country, phone: payload.shipping_address.phone, } : undefined, lines: payload.lines.map((line) => ({ id: line.id, productName: line.product_name, variantName: line.variant_name, quantity: line.quantity, totalPrice: { gross: { amount: parseInt(line.total_price_gross_amount), currency: line.currency || payload.channel.currency_code, }, }, })), total: { gross: { amount: parseInt(payload.total_gross_amount), currency: payload.channel.currency_code, }, }, languageCode: payload.language_code?.toUpperCase(), metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined, }; } // Extract tracking number from metadata function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } { if (!order.metadata) return {}; const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber"); const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl"); return { trackingNumber: trackingMeta?.value, trackingUrl: trackingUrlMeta?.value, }; } // Extract cancellation reason from metadata function getCancellationReason(order: Order): string | undefined { if (!order.metadata) return undefined; const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason"); return reasonMeta?.value; } // Webhook handlers async function handleOrderConfirmed(order: Order, eventType: string) { const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0); // Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED) if (eventType === "ORDER_CONFIRMED") { await orderNotificationService.sendOrderConfirmation(order); // Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED) // This prevents duplicate tracking when both events fire for the same order analyticsService.trackOrderReceived({ orderId: order.id, orderNumber: order.number, total: order.total.gross.amount, currency: order.total.gross.currency, itemCount, customerEmail: order.userEmail, eventType, }); analyticsService.trackRevenue({ amount: order.total.gross.amount, currency: order.total.gross.currency, orderId: order.id, orderNumber: order.number, }); } // Send admin notification for both events await orderNotificationService.sendOrderConfirmationToAdmin(order); } async function handleOrderFulfilled(order: Order) { const { trackingNumber, trackingUrl } = getTrackingInfo(order); await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl); await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl); } async function handleOrderCancelled(order: Order) { const reason = getCancellationReason(order); await orderNotificationService.sendOrderCancelled(order, reason); await orderNotificationService.sendOrderCancelledToAdmin(order, reason); } async function handleOrderFullyPaid(order: Order) { await orderNotificationService.sendOrderPaid(order); await orderNotificationService.sendOrderPaidToAdmin(order); } // Main webhook processor async function processWebhook(event: string, order: Order) { console.log(`Processing webhook event: ${event} for order ${order.id}`); switch (event) { case "ORDER_CREATED": case "ORDER_CONFIRMED": await handleOrderConfirmed(order, event); break; case "ORDER_FULFILLED": await handleOrderFulfilled(order); break; case "ORDER_CANCELLED": await handleOrderCancelled(order); break; case "ORDER_FULLY_PAID": await handleOrderFullyPaid(order); break; default: console.log(`Unsupported event: ${event}`); } } export async function POST(request: NextRequest) { try { console.log("=== WEBHOOK RECEIVED ==="); console.log("Timestamp:", new Date().toISOString()); const body = await request.json(); const headers = request.headers; const event = headers.get("saleor-event") as string; const domain = headers.get("saleor-domain"); console.log(`Received webhook: ${event} from ${domain}`); // Parse payload let orderPayload: SaleorOrderPayload | null = null; if (Array.isArray(body) && body.length > 0) { orderPayload = body[0] as SaleorOrderPayload; } else if (body.data && Array.isArray(body.data)) { orderPayload = body.data[0] as SaleorOrderPayload; } if (!orderPayload) { console.error("No order found in webhook payload"); return NextResponse.json({ error: "No order in payload" }, { status: 400 }); } console.log("Order:", { id: orderPayload.id, number: orderPayload.number, email: orderPayload.user_email, }); if (!event) { return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 }); } const normalizedEvent = event.toUpperCase(); if (!SUPPORTED_EVENTS.includes(normalizedEvent)) { console.log(`Event ${event} not supported, skipping`); return NextResponse.json({ success: true, message: "Event not supported" }); } const order = convertPayloadToOrder(orderPayload); await processWebhook(normalizedEvent, order); return NextResponse.json({ success: true }); } catch (error) { console.error("Webhook processing error:", error); return NextResponse.json( { error: "Internal server error", details: String(error) }, { status: 500 } ); } } export async function GET() { return NextResponse.json({ status: "ok", message: "Saleor webhook endpoint is active", supportedEvents: SUPPORTED_EVENTS, }); }