This commit is contained in:
Jun-te Kim 2026-02-01 18:33:55 +00:00
parent 2e714534b9
commit 11ab825cf6
2 changed files with 235 additions and 189 deletions

View file

@ -21,205 +21,248 @@ import {
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;
let eventId: string | undefined;
try {
event = stripe.webhooks.constructEvent(
console.log("🔔 Stripe webhook received");
// --------------------------------------------------
// 0⃣ Verify Stripe signature
// --------------------------------------------------
const sig = req.headers.get("stripe-signature");
if (!sig) {
console.error("❌ Missing stripe-signature header");
return NextResponse.json(
{ error: "Missing Stripe signature" },
{ status: 400 }
);
}
const body = await req.text();
const 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 });
}
eventId = event.id;
const session = event.data.object as Stripe.Checkout.Session;
console.log("✅ Event verified", {
id: event.id,
type: event.type,
});
// --------------------------------------------------
// 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,
},
],
// --------------------------------------------------
// 🔕 Only handle checkout.session.completed
// --------------------------------------------------
if (event.type !== "checkout.session.completed") {
console.log("⏭️ Ignored event type:", event.type);
return NextResponse.json({ ignored: true });
}
);
const invoice = invoiceResponse.body.invoices?.[0];
if (!invoice?.invoiceID) {
throw new Error("Failed to create Xero invoice");
const session = event.data.object as Stripe.Checkout.Session;
console.log("🧾 Checkout session", {
id: session.id,
amount_total: session.amount_total,
currency: session.currency,
customer_details: session.customer_details,
});
// --------------------------------------------------
// 1⃣ Stripe account context
// --------------------------------------------------
const stripeAccountId =
req.headers.get("stripe-account") ??
(process.env.NODE_ENV === "development"
? "acct_1Sds1LB99GOwj1Ea" // DEV ONLY
: null);
console.log("🔑 Stripe account context", { stripeAccountId });
if (!stripeAccountId) {
throw new Error("Missing stripe-account header in production");
}
// --------------------------------------------------
// 2⃣ IDEMPOTENCY CHECK
// --------------------------------------------------
const existing = await db
.select()
.from(processedStripeEvents)
.where(eq(processedStripeEvents.stripeEventId, event.id))
.limit(1);
console.log("🧠 Idempotency check", {
eventId: event.id,
alreadyProcessed: existing.length > 0,
});
if (existing.length > 0) {
return NextResponse.json({ received: true });
}
// --------------------------------------------------
// 3⃣ Stripe account → user
// --------------------------------------------------
const [stripeAccount] = await db
.select()
.from(stripeAccounts)
.where(eq(stripeAccounts.stripeAccountId, stripeAccountId))
.limit(1);
console.log("👤 Stripe account lookup", { stripeAccount });
if (!stripeAccount) {
throw new Error(`Stripe account not registered: ${stripeAccountId}`);
}
// --------------------------------------------------
// 4⃣ User → Xero connection
// --------------------------------------------------
const [xeroConn] = await db
.select()
.from(xeroConnections)
.where(eq(xeroConnections.userId, stripeAccount.userId))
.limit(1);
console.log("📘 Xero connection", {
tenantId: xeroConn?.tenantId,
salesAccountCode: xeroConn?.salesAccountCode,
});
if (!xeroConn) {
throw new Error("User has no Xero connection");
}
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) {
throw new Error("Missing customer email");
}
const name =
session.customer_details?.business_name ??
session.customer_details?.name ??
email;
console.log("📨 Resolving Xero contact", { 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}`);
}
console.log("🧮 Creating invoice", {
amount,
currency: currencyKey,
accountCode: xeroConn.salesAccountCode,
});
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 });
} catch (err: any) {
console.error("🔥 WEBHOOK 500", {
eventId,
message: err?.message,
stack: err?.stack,
});
return NextResponse.json(
{
error: "Webhook processing failed",
message: err?.message,
eventId,
},
{ status: 500 }
);
}
// --------------------------------------------------
// 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 });
}

View file

@ -0,0 +1,3 @@
echo "note you need to do 'stripe login' to make the below command work"
stripe listen --forward-to http://localhost:3000/api/stripe/webhook