export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const revalidate = 0; import { NextRequest, NextResponse } from "next/server"; import Stripe from "stripe"; import { Invoice, CurrencyCode, Payment, LineAmountTypes} from "xero-node"; import { eq } from "drizzle-orm"; import { getStripe } from "@/lib/stripe/service"; import { getXeroClient } from "@/lib/xero/service"; import { getValidXeroAccessToken } from "@/lib/xero/auth"; import { db } from "@/lib/db"; import { stripeAccounts, xeroConnections, processedStripeEvents, } from "@/lib/schema"; const stripe = getStripe(); export async function POST(req: NextRequest) { try { console.log("🔔 [WEBHOOK] Received request"); // -------------------------------------------------- // 0️⃣ Verify Stripe signature // -------------------------------------------------- const sig = req.headers.get("stripe-signature"); if (!sig) { console.error("❌ [WEBHOOK] Missing Stripe signature"); return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 }); } const body = await req.text(); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET! ); console.log("✅ [WEBHOOK] Signature verified", { eventType: event.type, eventId: event.id }); } catch (err: any) { console.error("❌ [WEBHOOK] Invalid Stripe signature", err.message); return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); } // -------------------------------------------------- // 🔒 SINGLE ENTRY POINT // -------------------------------------------------- if (event.type !== "checkout.session.completed") { console.log("⏭️ [WEBHOOK] Ignoring event type:", event.type); return NextResponse.json({ ignored: true }); } console.log("🎯 [WEBHOOK] Processing checkout.session.completed"); // -------------------------------------------------- // 1️⃣ Stripe account context // -------------------------------------------------- let stripeAccountId = event.account; // Development mode: use fallback account ID if (!stripeAccountId && process.env.NODE_ENV === "development") { stripeAccountId = process.env.STRIPE_DEV_ACCOUNT_ID || "acct_1Sds1LB99GOwj1Ea"; console.log("🔧 [WEBHOOK] Using dev fallback account ID:", stripeAccountId); } console.log("📍 [WEBHOOK] Stripe account ID:", stripeAccountId); if (!stripeAccountId) { console.error("❌ [WEBHOOK] Missing Stripe connected account on event"); return NextResponse.json( { error: "Missing Stripe connected account on event" }, { status: 400 } ); } // -------------------------------------------------- // 2️⃣ IDEMPOTENCY CHECK // -------------------------------------------------- const existing = await db .select() .from(processedStripeEvents) .where(eq(processedStripeEvents.stripeEventId, event.id)) .limit(1); if (existing.length > 0) { console.log("⏭️ [WEBHOOK] Event already processed:", event.id); return NextResponse.json({ received: true }); } // -------------------------------------------------- // 3️⃣ Stripe account → user // -------------------------------------------------- const [stripeAccount] = await db .select() .from(stripeAccounts) .where(eq(stripeAccounts.stripeAccountId, stripeAccountId)) .limit(1); if (!stripeAccount) { console.error("❌ [WEBHOOK] Stripe account not found in DB:", stripeAccountId); return NextResponse.json( { error: "Stripe account not registered" }, { status: 500 } ); } console.log("✅ [WEBHOOK] Found stripe account, userId:", stripeAccount.userId); // -------------------------------------------------- // 4️⃣ User → Xero connection // -------------------------------------------------- const [xeroConn] = await db .select() .from(xeroConnections) .where(eq(xeroConnections.userId, stripeAccount.userId)) .limit(1); if (!xeroConn) { console.error("❌ [WEBHOOK] User has no Xero connection, userId:", stripeAccount.userId); return NextResponse.json( { error: "User has no Xero connection" }, { status: 500 } ); } console.log("✅ [WEBHOOK] Found Xero connection", { tenantId: xeroConn.tenantId, salesAccountCode: xeroConn.salesAccountCode, stripeClearingAccountCode: xeroConn.stripeClearingAccountCode, }); // -------------------------------------------------- // 5️⃣ Get VALID Xero access token // -------------------------------------------------- const accessToken = await getValidXeroAccessToken(stripeAccount.userId); const xero = getXeroClient(accessToken); console.log("✅ [WEBHOOK] Got Xero access token"); // -------------------------------------------------- // 6️⃣ Create INCLUSIVE DRAFT invoice + apply payment // -------------------------------------------------- const session = event.data.object as Stripe.Checkout.Session; const email = session.customer_details?.email; console.log("📧 [WEBHOOK] Customer email:", email); if (!email) { console.error("❌ [WEBHOOK] Missing customer email"); return NextResponse.json({ error: "Missing customer email" }, { status: 400 }); } const name = session.customer_details?.business_name ?? session.customer_details?.name ?? email; console.log("👤 [WEBHOOK] Customer name:", name); // --- Contact (email-only is fine for v1) console.log("🔍 [WEBHOOK] Looking for existing contact with email:", email); const contactsResponse = await xero.accountingApi.getContacts( xeroConn.tenantId, undefined, `EmailAddress=="${email}"` ); let contact = contactsResponse.body.contacts?.[0]; if (!contact) { console.log("➕ [WEBHOOK] Creating new contact"); const created = await xero.accountingApi.createContacts( xeroConn.tenantId, { contacts: [{ name, emailAddress: email }] } ); contact = created.body.contacts?.[0]; } else { console.log("✅ [WEBHOOK] Found existing contact:", contact.contactID); } if (!contact?.contactID) { console.error("❌ [WEBHOOK] Failed to resolve Xero contact"); throw new Error("Failed to resolve Xero contact"); } console.log("✅ [WEBHOOK] Contact resolved:", contact.contactID); if (!session.amount_total || !session.currency) { console.error("❌ [WEBHOOK] Stripe session missing amount or currency", { amount_total: session.amount_total, currency: session.currency, }); throw new Error("Stripe session missing amount or currency"); } const amount = session.amount_total / 100; const currencyKey = session.currency.toUpperCase() as keyof typeof CurrencyCode; const today = new Date().toISOString().slice(0, 10); console.log("💰 [WEBHOOK] Invoice details", { amount, currency: session.currency, date: today, accountCode: xeroConn.salesAccountCode, }); // --- Create AUTHROISED, VAT-INCLUSIVE invoice const checkoutSessionId = session.id; const paymentIntentId = typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent?.id; console.log("📝 [WEBHOOK] Creating invoice..."); const invoiceResponse = await xero.accountingApi.createInvoices( xeroConn.tenantId, { invoices: [ { type: Invoice.TypeEnum.ACCREC, status: Invoice.StatusEnum.AUTHORISED, contact: { contactID: contact.contactID }, date: today, dueDate: today, // 🔑 INCLUSIVE VAT lineAmountTypes: LineAmountTypes.Inclusive, lineItems: [ { description: `Stripe payment — Checkout: ${checkoutSessionId} | Payment: ${paymentIntentId}`, quantity: 1, unitAmount: amount, taxType: "OUTPUT2", // UK VAT 20% accountCode: xeroConn.salesAccountCode!, }, ], currencyCode: CurrencyCode[currencyKey], reference: session.id, }, ], } ); const invoice = invoiceResponse.body.invoices?.[0]; if (!invoice?.invoiceID || !invoice.total) { console.error("❌ [WEBHOOK] Failed to create Xero invoice", invoiceResponse); throw new Error("Failed to create Xero invoice"); } console.log("✅ [WEBHOOK] Invoice created:", invoice.invoiceID, "total:", invoice.total); // --- Apply payment immediately console.log("💳 [WEBHOOK] Applying payment to invoice...", { invoiceID: invoice.invoiceID, accountCode: xeroConn.stripeClearingAccountCode, amount: invoice.total, }); await xero.accountingApi.createPayment( xeroConn.tenantId, { invoice: { invoiceID: invoice.invoiceID }, account: { code: xeroConn.stripeClearingAccountCode! }, amount: invoice.total, // 🔑 exact, VAT-safe date: today, reference: session.id, } as Payment ); console.log("✅ [WEBHOOK] Payment applied successfully"); // -------------------------------------------------- // 7️⃣ Record idempotency (LAST) // -------------------------------------------------- await db.insert(processedStripeEvents).values({ stripeEventId: event.id, stripeAccountId, }); console.log("✅ Stripe checkout processed end-to-end", { eventId: event.id, invoiceId: invoice.invoiceID, }); return NextResponse.json({ received: true }); } catch (error: any) { console.error("❌ [WEBHOOK] ERROR:", error.message); console.error("❌ [WEBHOOK] Stack:", error.stack); return NextResponse.json({ error: error.message }, { status: 500 }); } }