juntekim.com/stripe_to_invoice/app/api/stripe/webhook/route.ts
Jun-te Kim 6b35b91ab1 stripe
2026-01-20 23:25:03 +00:00

267 lines
7.7 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";
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 });
}