361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
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) {
|
||
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);
|
||
|
||
// --------------------------------------------------
|
||
// 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 });
|
||
}
|
||
}
|