juntekim.com/stripe_to_invoice/app/api/stripe/webhook/route.ts
2026-02-01 21:36:58 +00:00

234 lines
6.8 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, 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 });
}