save
This commit is contained in:
parent
2e714534b9
commit
11ab825cf6
2 changed files with 235 additions and 189 deletions
|
|
@ -21,205 +21,248 @@ import {
|
||||||
const stripe = getStripe();
|
const stripe = getStripe();
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
// --------------------------------------------------
|
let eventId: string | undefined;
|
||||||
// 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 {
|
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,
|
body,
|
||||||
sig,
|
sig,
|
||||||
process.env.STRIPE_WEBHOOK_SECRET!
|
process.env.STRIPE_WEBHOOK_SECRET!
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
|
||||||
console.error("❌ Invalid Stripe signature", err.message);
|
|
||||||
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------
|
eventId = event.id;
|
||||||
// 🔕 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;
|
console.log("✅ Event verified", {
|
||||||
|
id: event.id,
|
||||||
|
type: event.type,
|
||||||
|
});
|
||||||
|
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
// 1️⃣ Stripe account context
|
// 🔕 Only handle checkout.session.completed
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
const stripeAccountId =
|
if (event.type !== "checkout.session.completed") {
|
||||||
req.headers.get("stripe-account") ??
|
console.log("⏭️ Ignored event type:", event.type);
|
||||||
(process.env.NODE_ENV === "development"
|
return NextResponse.json({ ignored: true });
|
||||||
? "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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const invoice = invoiceResponse.body.invoices?.[0];
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
if (!invoice?.invoiceID) {
|
|
||||||
throw new Error("Failed to create Xero invoice");
|
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 });
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
stripe_to_invoice/stripe_webhook.sh
Normal file
3
stripe_to_invoice/stripe_webhook.sh
Normal 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
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue