juntekim.com/stripe_to_invoice/app/api/stripe/webhook/route.ts
2026-01-21 19:41:03 +00:00

225 lines
6.5 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) {
// --------------------------------------------------
// 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 });
}