From 11ab825cf61a7d473f7cc298ddbe6d94b5b5fed2 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Sun, 1 Feb 2026 18:33:55 +0000 Subject: [PATCH] save --- .../app/api/stripe/webhook/route.ts | 421 ++++++++++-------- stripe_to_invoice/stripe_webhook.sh | 3 + 2 files changed, 235 insertions(+), 189 deletions(-) create mode 100644 stripe_to_invoice/stripe_webhook.sh diff --git a/stripe_to_invoice/app/api/stripe/webhook/route.ts b/stripe_to_invoice/app/api/stripe/webhook/route.ts index 558e8b1..6323d43 100644 --- a/stripe_to_invoice/app/api/stripe/webhook/route.ts +++ b/stripe_to_invoice/app/api/stripe/webhook/route.ts @@ -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 }); } diff --git a/stripe_to_invoice/stripe_webhook.sh b/stripe_to_invoice/stripe_webhook.sh new file mode 100644 index 0000000..2f80f78 --- /dev/null +++ b/stripe_to_invoice/stripe_webhook.sh @@ -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 +