juntekim.com/stripe_to_invoice/app/api/stripe/webhook/route.ts
2026-02-21 11:19:06 +00:00

377 lines
12 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.

This file contains Unicode characters that might be confused with other characters. 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 { checkSubscriptionStatus } from "@/lib/subscription/check-status";
import { db } from "@/lib/db";
import {
stripeAccounts,
xeroConnections,
processedStripeEvents,
} from "@/lib/schema";
const stripe = getStripe();
export async function POST(req: NextRequest) {
try {
console.log("🔔 [WEBHOOK] Received request");
// --------------------------------------------------
// 0⃣ Verify Stripe signature
// --------------------------------------------------
const sig = req.headers.get("stripe-signature");
if (!sig) {
console.error("❌ [WEBHOOK] Missing Stripe signature");
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!
);
console.log("✅ [WEBHOOK] Signature verified", { eventType: event.type, eventId: event.id });
} catch (err: any) {
console.error("❌ [WEBHOOK] Invalid Stripe signature", err.message);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// --------------------------------------------------
// 🔒 SINGLE ENTRY POINT
// --------------------------------------------------
if (event.type !== "checkout.session.completed") {
console.log("⏭️ [WEBHOOK] Ignoring event type:", event.type);
return NextResponse.json({ ignored: true });
}
console.log("🎯 [WEBHOOK] Processing checkout.session.completed");
// --------------------------------------------------
// 1⃣ Stripe account context
// --------------------------------------------------
let stripeAccountId = event.account;
// Development mode: use fallback account ID
if (!stripeAccountId && process.env.NODE_ENV === "development") {
stripeAccountId = process.env.STRIPE_DEV_ACCOUNT_ID || "acct_1Sds1LB99GOwj1Ea";
console.log("🔧 [WEBHOOK] Using dev fallback account ID:", stripeAccountId);
}
console.log("📍 [WEBHOOK] Stripe account ID:", stripeAccountId);
if (!stripeAccountId) {
console.error("❌ [WEBHOOK] Missing Stripe connected account on event");
return NextResponse.json(
{ error: "Missing Stripe connected account on event" },
{ 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("⏭️ [WEBHOOK] 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("❌ [WEBHOOK] Stripe account not found in DB:", stripeAccountId);
return NextResponse.json(
{ error: "Stripe account not registered" },
{ status: 500 }
);
}
console.log("✅ [WEBHOOK] Found stripe account, userId:", stripeAccount.userId);
// --------------------------------------------------
// 3⃣a Check subscription status
// --------------------------------------------------
const subscriptionStatus = await checkSubscriptionStatus(stripeAccount.userId);
if (subscriptionStatus.status !== "active" && subscriptionStatus.status !== "canceling" && subscriptionStatus.status !== "trialing") {
console.log("⏭️ [WEBHOOK] User subscription not active:", {
userId: stripeAccount.userId,
status: subscriptionStatus.status,
});
return NextResponse.json({ received: true });
}
console.log("✅ [WEBHOOK] User subscription is valid:", subscriptionStatus.status);
// --------------------------------------------------
// 4⃣ User → Xero connection
// --------------------------------------------------
const [xeroConn] = await db
.select()
.from(xeroConnections)
.where(eq(xeroConnections.userId, stripeAccount.userId))
.limit(1);
if (!xeroConn) {
console.error("❌ [WEBHOOK] User has no Xero connection, userId:", stripeAccount.userId);
return NextResponse.json(
{ error: "User has no Xero connection" },
{ status: 500 }
);
}
console.log("✅ [WEBHOOK] Found Xero connection", {
tenantId: xeroConn.tenantId,
salesAccountCode: xeroConn.salesAccountCode,
stripeClearingAccountCode: xeroConn.stripeClearingAccountCode,
});
// --------------------------------------------------
// 5⃣ Get VALID Xero access token
// --------------------------------------------------
const accessToken = await getValidXeroAccessToken(stripeAccount.userId);
const xero = getXeroClient(accessToken);
console.log("✅ [WEBHOOK] Got Xero access token");
// --------------------------------------------------
// 6⃣ Create INCLUSIVE DRAFT invoice + apply payment
// --------------------------------------------------
const session = event.data.object as Stripe.Checkout.Session;
const email = session.customer_details?.email;
console.log("📧 [WEBHOOK] Customer email:", email);
if (!email) {
console.error("❌ [WEBHOOK] Missing customer email");
return NextResponse.json({ error: "Missing customer email" }, { status: 400 });
}
const name =
session.customer_details?.business_name ??
session.customer_details?.name ??
email;
console.log("👤 [WEBHOOK] Customer name:", name);
// --- Contact resolution: email → name → create with ZWS retry (up to 5 times)
console.log("🔍 [WEBHOOK] Looking for existing contact with email:", email);
let contactsResponse = await xero.accountingApi.getContacts(
xeroConn.tenantId,
undefined,
`EmailAddress=="${email}"`
);
let contact = contactsResponse.body.contacts?.[0];
// If not found by email, search by name
if (!contact) {
console.log("🔍 [WEBHOOK] Email not found, searching by name:", name);
contactsResponse = await xero.accountingApi.getContacts(
xeroConn.tenantId,
undefined,
`Name=="${name}"`
);
contact = contactsResponse.body.contacts?.[0];
if (contact) {
console.log("✅ [WEBHOOK] Found existing contact by name:", contact.contactID);
}
} else {
console.log("✅ [WEBHOOK] Found existing contact by email:", contact.contactID);
}
// If still not found, create new contact (with retry logic for duplicate names)
if (!contact) {
console.log(" [WEBHOOK] Creating new contact");
let attempt = 0;
const maxAttempts = 5;
while (attempt < maxAttempts && !contact) {
let contactName = name;
// Add zero-width spaces for uniqueness on retries
if (attempt > 0) {
contactName = name + "\u200B".repeat(attempt);
console.log(`🔄 [WEBHOOK] Retry attempt ${attempt}/${maxAttempts - 1} with ${attempt} zero-width space(s)`);
}
try {
const created = await xero.accountingApi.createContacts(
xeroConn.tenantId,
{ contacts: [{ name: contactName, emailAddress: email }] }
);
contact = created.body.contacts?.[0];
if (contact) {
console.log(`✅ [WEBHOOK] Contact created successfully on attempt ${attempt + 1}:`, contact.contactID);
break;
}
} catch (err: any) {
console.log(`⚠️ [WEBHOOK] Attempt ${attempt + 1} failed:`, err.message);
// Try to fetch existing contact by name
if (attempt < maxAttempts - 1) {
const existingByName = await xero.accountingApi.getContacts(
xeroConn.tenantId,
undefined,
`Name=="${name}"`
);
if (existingByName.body.contacts?.length) {
contact = existingByName.body.contacts[0];
console.log("✅ [WEBHOOK] Reusing existing contact with duplicate name:", contact.contactID);
break;
}
}
}
attempt++;
}
if (!contact) {
console.error("❌ [WEBHOOK] Failed to create or reuse contact after 5 attempts");
throw new Error("Failed to create Xero contact after multiple attempts");
}
}
if (!contact?.contactID) {
console.error("❌ [WEBHOOK] Failed to resolve Xero contact");
throw new Error("Failed to resolve Xero contact");
}
console.log("✅ [WEBHOOK] Contact resolved:", contact.contactID);
if (!session.amount_total || !session.currency) {
console.error("❌ [WEBHOOK] Stripe session missing amount or currency", {
amount_total: session.amount_total,
currency: 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);
console.log("💰 [WEBHOOK] Invoice details", {
amount,
currency: session.currency,
date: today,
accountCode: xeroConn.salesAccountCode,
});
// --- Create AUTHROISED, VAT-INCLUSIVE invoice
const checkoutSessionId = session.id;
const paymentIntentId =
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id;
console.log("📝 [WEBHOOK] Creating invoice...");
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) {
console.error("❌ [WEBHOOK] Failed to create Xero invoice", invoiceResponse);
throw new Error("Failed to create Xero invoice");
}
console.log("✅ [WEBHOOK] Invoice created:", invoice.invoiceID, "total:", invoice.total);
// --- Apply payment immediately
console.log("💳 [WEBHOOK] Applying payment to invoice...", {
invoiceID: invoice.invoiceID,
accountCode: xeroConn.stripeClearingAccountCode,
amount: invoice.total,
});
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
);
console.log("✅ [WEBHOOK] Payment applied successfully");
// --------------------------------------------------
// 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 });
} catch (error: any) {
console.error("❌ [WEBHOOK] ERROR:", error.message);
console.error("❌ [WEBHOOK] Stack:", error.stack);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}