115 lines
3 KiB
TypeScript
115 lines
3 KiB
TypeScript
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)
|
||
);
|
||
}
|