export const runtime = "nodejs"; import { NextRequest, NextResponse } from "next/server"; import { getStripe } from "@/lib/stripe/service"; import { XeroClient, Invoice, CurrencyCode } from "xero-node"; import Stripe from "stripe"; import { db } from "@/lib/db"; import { stripeAccounts, xeroConnections, processedStripeEvents, } from "@/lib/schema"; import { eq } from "drizzle-orm"; import { getXeroClient } from "@/lib/xero/service"; 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") ?? "acct_1Sds1LB99GOwj1Ea"; // DEV ONLY // -------------------------------------------------- // 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) { console.error("❌ Stripe account not registered:", stripeAccountId); 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) { console.error("❌ No Xero connection for user:", stripeAccount.userId); return NextResponse.json( { error: "User has no Xero connection" }, { status: 500 } ); } if (!xeroConn.salesAccountCode || !xeroConn.stripeClearingAccountCode) { throw new Error( "Xero account codes not configured. User must select Sales and Stripe Clearing accounts." ); } // -------------------------------------------------- // 5️⃣ Init Xero client + refresh token if needed // -------------------------------------------------- const xero = getXeroClient(); xero.setTokenSet({ access_token: xeroConn.accessToken, refresh_token: xeroConn.refreshToken, expires_at: xeroConn.expiresAt.getTime(), token_type: "Bearer", }); const now = Date.now(); if (xeroConn.expiresAt.getTime() <= now + 60_000) { console.log("🔄 Refreshing Xero token"); const newTokenSet = await xero.refreshToken(); await db .update(xeroConnections) .set({ accessToken: newTokenSet.access_token!, refreshToken: newTokenSet.refresh_token!, expiresAt: new Date(newTokenSet.expires_at!), }) .where(eq(xeroConnections.id, xeroConn.id)); xero.setTokenSet(newTokenSet); } // -------------------------------------------------- // 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 // -------------------------------------------------- const amount = session.amount_total! / 100; if (!session.currency) { throw new Error("Stripe session missing currency"); } const currencyCode = session.currency.toUpperCase() as keyof typeof CurrencyCode; if (!(currencyCode in CurrencyCode)) { throw new Error(`Unsupported currency: ${session.currency}`); } const invoiceResponse = await xero.accountingApi.createInvoices( xeroConn.tenantId, { invoices: [ { type: Invoice.TypeEnum.ACCREC, status: Invoice.StatusEnum.AUTHORISED, contact: { contactID: contact.contactID }, lineItems: [ { description: `Stripe payment (${session.id})`, quantity: 1, unitAmount: amount, accountCode: xeroConn.salesAccountCode, }, ], currencyCode: CurrencyCode[currencyCode], reference: session.id, }, ], } ); const invoice = invoiceResponse.body.invoices?.[0]; if (!invoice?.invoiceID) { throw new Error("Failed to create Xero invoice"); } // -------------------------------------------------- // 8️⃣ Mark invoice as PAID to Stripe Clearing // // STRIPE CLEARING (v1 behaviour) // - Invoices are marked as PAID to a Stripe Clearing account // - Stripe fees are NOT yet recorded in v1 // // TODO (planned): // - Record Stripe fees as an expense // - Reconcile Stripe payouts automatically // -------------------------------------------------- const paymentReference = typeof session.payment_intent === "string" ? session.payment_intent : session.id; await xero.accountingApi.createPayments(xeroConn.tenantId, { payments: [ { invoice: { invoiceID: invoice.invoiceID }, amount, date: new Date().toISOString().slice(0, 10), reference: paymentReference, account: { code: xeroConn.stripeClearingAccountCode, }, }, ], }); // -------------------------------------------------- // 9️⃣ Record idempotency AFTER success // -------------------------------------------------- await db.insert(processedStripeEvents).values({ stripeEventId: event.id, stripeAccountId, }); console.log("✅ Stripe payment fully processed", { eventId: event.id, invoiceId: invoice.invoiceID, stripeAccountId, }); return NextResponse.json({ received: true }); }