juntekim.com/stripe_to_invoice/app/api/xero/callback/route.ts
2026-02-01 22:36:52 +00:00

115 lines
3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { xeroConnections } from "@/lib/schema/xeroConnections";
import { getUserFromSession } from "@/lib/auth/get-user";
type XeroTokenResponse = {
access_token: string;
refresh_token: string;
expires_in: number; // seconds
};
type XeroTenant = {
tenantId: string;
};
export async function GET(req: NextRequest) {
// 0⃣ Auth single source of truth
const user = await getUserFromSession();
if (!user) {
return NextResponse.redirect(
new URL("/login", process.env.APP_URL)
);
}
const userId = user.id;
// 1⃣ Read OAuth code
const { searchParams } = new URL(req.url);
const code = searchParams.get("code");
if (!code) {
return NextResponse.json(
{ error: "Missing OAuth code" },
{ status: 400 }
);
}
// 2⃣ Exchange code for tokens
const tokenRes = await fetch("https://identity.xero.com/connect/token", {
method: "POST",
headers: {
Authorization:
"Basic " +
Buffer.from(
`${process.env.XERO_CLIENT_ID}:${process.env.XERO_CLIENT_SECRET}`
).toString("base64"),
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: process.env.XERO_REDIRECT_URI!,
}),
});
if (!tokenRes.ok) {
const text = await tokenRes.text();
console.error("Xero token exchange failed:", text);
return NextResponse.redirect(
new URL("/connect/xero?error=token_failed", process.env.APP_URL)
);
}
const tokenData = (await tokenRes.json()) as XeroTokenResponse;
// 3⃣ Fetch tenant (organisation)
const tenantRes = await fetch("https://api.xero.com/connections", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const tenants = (await tenantRes.json()) as XeroTenant[];
const tenantId = tenants[0]?.tenantId;
if (!tenantId) {
return NextResponse.json(
{ error: "No Xero organisation found" },
{ status: 400 }
);
}
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000);
// 4⃣ Idempotent upsert (same fix as Stripe)
await db
.insert(xeroConnections)
.values({
userId,
tenantId,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt,
salesAccountCode: "200", // default on first connect
stripeClearingAccountCode: "090", // default on first connect
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: xeroConnections.userId,
set: {
tenantId,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt,
updatedAt: new Date(),
// deliberately NOT updating account codes
},
});
// 5⃣ Done
return NextResponse.redirect(
new URL("/connect/xero/success", process.env.APP_URL)
);
}