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 } 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) { // -------------------------------------------------- // 0️⃣ Verify Stripe signature // -------------------------------------------------- const sig = req.headers.get("stripe-signature"); if (!sig) { 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! ); } catch (err: any) { console.error("❌ Invalid Stripe signature", err.message); return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); } // -------------------------------------------------- // 🔕 Only handle checkout.session.completed // -------------------------------------------------- if (event.type !== "checkout.session.completed") { return NextResponse.json({ ignored: true }); } const session = event.data.object as Stripe.Checkout.Session; // -------------------------------------------------- // 1️⃣ Stripe account context // -------------------------------------------------- const stripeAccountId = req.headers.get("stripe-account") ?? (process.env.NODE_ENV === "development" ? "acct_1Sds1LB99GOwj1Ea" // DEV ONLY : null); if (!stripeAccountId) { console.error("❌ Missing stripe-account header in production"); return NextResponse.json( { error: "Missing Stripe account context" }, { 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("⏭️ 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) { return NextResponse.json( { error: "Stripe account not registered" }, { status: 500 } ); } // -------------------------------------------------- // 4️⃣ User → Xero connection // -------------------------------------------------- const [xeroConn] = await db .select() .from(xeroConnections) .where(eq(xeroConnections.userId, stripeAccount.userId)) .limit(1); if (!xeroConn) { return NextResponse.json( { error: "User has no Xero connection" }, { status: 500 } ); } if (!xeroConn.salesAccountCode) { throw new Error("Sales account code not configured"); } // -------------------------------------------------- // 5️⃣ Get VALID Xero access token // -------------------------------------------------- const accessToken = await getValidXeroAccessToken(stripeAccount.userId); const xero = getXeroClient(accessToken); // -------------------------------------------------- // 6️⃣ Resolve contact (email-only) // -------------------------------------------------- const email = session.customer_details?.email; if (!email) { return NextResponse.json( { error: "Missing customer email" }, { status: 400 } ); } const name = session.customer_details?.business_name ?? session.customer_details?.name ?? email; const contactsResponse = await xero.accountingApi.getContacts( xeroConn.tenantId, undefined, `EmailAddress=="${email}"` ); let contact = contactsResponse.body.contacts?.[0]; if (!contact) { const created = await xero.accountingApi.createContacts( xeroConn.tenantId, { contacts: [{ name, emailAddress: email }] } ); contact = created.body.contacts?.[0]; } if (!contact?.contactID) { throw new Error("Failed to resolve Xero contact"); } // -------------------------------------------------- // 7️⃣ Create AUTHORISED invoice (NO PAYMENT) // -------------------------------------------------- if (!session.amount_total || !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; if (!(currencyKey in CurrencyCode)) { throw new Error(`Unsupported currency: ${session.currency}`); } const today = new Date().toISOString().slice(0, 10); 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, lineItems: [ { description: `Stripe payment (${session.id})`, quantity: 1, unitAmount: amount, accountCode: xeroConn.salesAccountCode, }, ], currencyCode: CurrencyCode[currencyKey], reference: session.id, }, ], } ); const invoice = invoiceResponse.body.invoices?.[0]; if (!invoice?.invoiceID) { throw new Error("Failed to create Xero invoice"); } // -------------------------------------------------- // 8️⃣ Record idempotency (LAST STEP) // -------------------------------------------------- await db.insert(processedStripeEvents).values({ stripeEventId: event.id, stripeAccountId, }); console.log("✅ Stripe → Xero invoice created", { eventId: event.id, invoiceId: invoice.invoiceID, stripeAccountId, }); return NextResponse.json({ received: true }); }