juntekim.com/stripe_to_invoice/app/api/stripe/webhook/route.ts
2026-02-01 20:03:08 +00:00

261 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
let eventId: string | undefined;
try {
console.log("🔔 Stripe webhook received");
// --------------------------------------------------
// 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();
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
eventId = event.id;
console.log("✅ Event verified", {
id: event.id,
type: event.type,
});
// --------------------------------------------------
// 🔕 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) {
throw new Error("Missing stripe-account header in production");
}
// --------------------------------------------------
// 2⃣ IDEMPOTENCY CHECK
// --------------------------------------------------
const existing = await db
.select()
.from(processedStripeEvents)
.where(eq(processedStripeEvents.stripeEventId, event.id))
.limit(1);
if (existing.length > 0) {
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) {
throw new Error(`Stripe account not registered: ${stripeAccountId}`);
}
// --------------------------------------------------
// 4⃣ User → Xero connection
// --------------------------------------------------
const [xeroConn] = await db
.select()
.from(xeroConnections)
.where(eq(xeroConnections.userId, stripeAccount.userId))
.limit(1);
if (!xeroConn) {
throw new Error("User has no Xero connection");
}
if (!xeroConn.salesAccountCode) {
throw new Error("Sales account code not configured");
}
if (!xeroConn.stripeClearingAccountCode) {
throw new Error("Stripe clearing 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) {
throw new Error("Missing customer email");
}
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
// --------------------------------------------------
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");
}
// --------------------------------------------------
// 7⃣.5️⃣ Create PAYMENT → marks invoice as PAID
// --------------------------------------------------
await xero.accountingApi.createPayment(
xeroConn.tenantId,
{
payment: {
invoice: {
invoiceID: invoice.invoiceID,
},
account: {
code: xeroConn.stripeClearingAccountCode,
},
amount,
date: today,
reference: `Stripe ${session.id}`,
},
}
);
// --------------------------------------------------
// 8⃣ Record idempotency (LAST STEP)
// --------------------------------------------------
await db.insert(processedStripeEvents).values({
stripeEventId: event.id,
stripeAccountId,
});
console.log("✅ Stripe → Xero invoice PAID", {
eventId: event.id,
invoiceId: invoice.invoiceID,
stripeAccountId,
});
return NextResponse.json({ received: true });
} catch (err: any) {
console.error("🔥 WEBHOOK 500", {
eventId,
message: err?.message,
stack: err?.stack,
});
return NextResponse.json(
{
error: "Webhook processing failed",
message: err?.message,
eventId,
},
{ status: 500 }
);
}
}