Move analytics tracking inside ORDER_CONFIRMED conditional block so revenue is only tracked once when order is confirmed, not twice (once for ORDER_CREATED and once for ORDER_CONFIRMED).
296 lines
8.9 KiB
TypeScript
296 lines
8.9 KiB
TypeScript
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<string, string>;
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
}
|