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) { // -------------------------------------------------- // 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 }); } // -------------------------------------------------- // 🔒 SINGLE ENTRY POINT // -------------------------------------------------- if (event.type !== "checkout.session.completed") { return NextResponse.json({ ignored: true }); } // -------------------------------------------------- // 1️⃣ Stripe account context // -------------------------------------------------- const stripeAccountId = req.headers.get("stripe-account") ?? (process.env.NODE_ENV === "development" ? "acct_1Sds1LB99GOwj1Ea" // DEV ONLY : null); if (!stripeAccountId) { 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 } ); } // -------------------------------------------------- // 5️⃣ Get VALID Xero access token // -------------------------------------------------- const accessToken = await getValidXeroAccessToken(stripeAccount.userId); const xero = getXeroClient(accessToken); // -------------------------------------------------- // 6️⃣ Create INCLUSIVE DRAFT invoice + apply payment // -------------------------------------------------- const session = event.data.object as Stripe.Checkout.Session; 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; // --- Contact (email-only is fine for v1) 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"); } 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; const today = new Date().toISOString().slice(0, 10); // --- Create AUTHROISED, VAT-INCLUSIVE invoice const checkoutSessionId = session.id; const paymentIntentId = typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent?.id; 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) { throw new Error("Failed to create Xero invoice"); } // --- Apply payment immediately 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 ); // -------------------------------------------------- // 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 }); }