Files
manoon-headless/src/app/api/webhooks/saleor/route.ts
Unchained 2e6668ff0d fix(webhook): prevent duplicate revenue tracking
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).
2026-03-25 20:35:39 +02:00

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,
});
}