269 lines
7.7 KiB
TypeScript
269 lines
7.7 KiB
TypeScript
export const runtime = "nodejs";
|
||
export const dynamic = "force-dynamic";
|
||
export const revalidate = 0;
|
||
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { getStripe } from "@/lib/stripe/service";
|
||
import { XeroClient, Invoice, CurrencyCode } from "xero-node";
|
||
import Stripe from "stripe";
|
||
import { db } from "@/lib/db";
|
||
import {
|
||
stripeAccounts,
|
||
xeroConnections,
|
||
processedStripeEvents,
|
||
} from "@/lib/schema";
|
||
import { eq } from "drizzle-orm";
|
||
import { getXeroClient } from "@/lib/xero/service";
|
||
|
||
|
||
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;
|
||
|
||
try {
|
||
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 });
|
||
}
|
||
|
||
const session = event.data.object as Stripe.Checkout.Session;
|
||
|
||
// --------------------------------------------------
|
||
// 1️⃣ Stripe account context
|
||
// --------------------------------------------------
|
||
const stripeAccountId =
|
||
req.headers.get("stripe-account") ??
|
||
"acct_1Sds1LB99GOwj1Ea"; // DEV ONLY
|
||
|
||
// --------------------------------------------------
|
||
// 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) {
|
||
console.error("❌ Stripe account not registered:", stripeAccountId);
|
||
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) {
|
||
console.error("❌ No Xero connection for user:", stripeAccount.userId);
|
||
return NextResponse.json(
|
||
{ error: "User has no Xero connection" },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
|
||
if (!xeroConn.salesAccountCode || !xeroConn.stripeClearingAccountCode) {
|
||
throw new Error(
|
||
"Xero account codes not configured. User must select Sales and Stripe Clearing accounts."
|
||
);
|
||
}
|
||
|
||
// --------------------------------------------------
|
||
// 5️⃣ Init Xero client + refresh token if needed
|
||
// --------------------------------------------------
|
||
const xero = getXeroClient();
|
||
|
||
xero.setTokenSet({
|
||
access_token: xeroConn.accessToken,
|
||
refresh_token: xeroConn.refreshToken,
|
||
expires_at: xeroConn.expiresAt.getTime(),
|
||
token_type: "Bearer",
|
||
});
|
||
|
||
const now = Date.now();
|
||
if (xeroConn.expiresAt.getTime() <= now + 60_000) {
|
||
console.log("🔄 Refreshing Xero token");
|
||
|
||
const newTokenSet = await xero.refreshToken();
|
||
|
||
await db
|
||
.update(xeroConnections)
|
||
.set({
|
||
accessToken: newTokenSet.access_token!,
|
||
refreshToken: newTokenSet.refresh_token!,
|
||
expiresAt: new Date(newTokenSet.expires_at!),
|
||
})
|
||
.where(eq(xeroConnections.id, xeroConn.id));
|
||
|
||
xero.setTokenSet(newTokenSet);
|
||
}
|
||
|
||
// --------------------------------------------------
|
||
// 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
|
||
// --------------------------------------------------
|
||
const amount = session.amount_total! / 100;
|
||
|
||
if (!session.currency) {
|
||
throw new Error("Stripe session missing currency");
|
||
}
|
||
|
||
const currencyCode = session.currency.toUpperCase() as keyof typeof CurrencyCode;
|
||
|
||
if (!(currencyCode in CurrencyCode)) {
|
||
throw new Error(`Unsupported currency: ${session.currency}`);
|
||
}
|
||
|
||
const invoiceResponse = await xero.accountingApi.createInvoices(
|
||
xeroConn.tenantId,
|
||
{
|
||
invoices: [
|
||
{
|
||
type: Invoice.TypeEnum.ACCREC,
|
||
status: Invoice.StatusEnum.AUTHORISED,
|
||
contact: { contactID: contact.contactID },
|
||
lineItems: [
|
||
{
|
||
description: `Stripe payment (${session.id})`,
|
||
quantity: 1,
|
||
unitAmount: amount,
|
||
accountCode: xeroConn.salesAccountCode,
|
||
},
|
||
],
|
||
currencyCode: CurrencyCode[currencyCode],
|
||
reference: session.id,
|
||
},
|
||
],
|
||
}
|
||
);
|
||
|
||
const invoice = invoiceResponse.body.invoices?.[0];
|
||
if (!invoice?.invoiceID) {
|
||
throw new Error("Failed to create Xero invoice");
|
||
}
|
||
|
||
// --------------------------------------------------
|
||
// 8️⃣ Mark invoice as PAID to Stripe Clearing
|
||
//
|
||
// STRIPE CLEARING (v1 behaviour)
|
||
// - Invoices are marked as PAID to a Stripe Clearing account
|
||
// - Stripe fees are NOT yet recorded in v1
|
||
//
|
||
// TODO (planned):
|
||
// - Record Stripe fees as an expense
|
||
// - Reconcile Stripe payouts automatically
|
||
// --------------------------------------------------
|
||
const paymentReference =
|
||
typeof session.payment_intent === "string"
|
||
? session.payment_intent
|
||
: session.id;
|
||
await xero.accountingApi.createPayments(xeroConn.tenantId, {
|
||
payments: [
|
||
{
|
||
invoice: { invoiceID: invoice.invoiceID },
|
||
amount,
|
||
date: new Date().toISOString().slice(0, 10),
|
||
reference: paymentReference,
|
||
account: {
|
||
code: xeroConn.stripeClearingAccountCode,
|
||
},
|
||
},
|
||
],
|
||
});
|
||
|
||
// --------------------------------------------------
|
||
// 9️⃣ Record idempotency AFTER success
|
||
// --------------------------------------------------
|
||
await db.insert(processedStripeEvents).values({
|
||
stripeEventId: event.id,
|
||
stripeAccountId,
|
||
});
|
||
|
||
console.log("✅ Stripe payment fully processed", {
|
||
eventId: event.id,
|
||
invoiceId: invoice.invoiceID,
|
||
stripeAccountId,
|
||
});
|
||
|
||
return NextResponse.json({ received: true });
|
||
}
|