added migration so that we can do refresh token
This commit is contained in:
parent
18312c939a
commit
8bae792d16
5 changed files with 110 additions and 14 deletions
|
|
@ -0,0 +1,6 @@
|
|||
ALTER TABLE public.xero_connections
|
||||
ADD COLUMN access_token TEXT NOT NULL,
|
||||
ADD COLUMN refresh_token TEXT NOT NULL,
|
||||
ADD COLUMN expires_at TIMESTAMPTZ NOT NULL;
|
||||
CREATE UNIQUE INDEX xero_connections_user_id_idx
|
||||
ON public.xero_connections(user_id);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
h1:vP7u0iEZCJNzW1k3wWjwrXVO7WsP44Hj8aa5BFUfn/c=
|
||||
h1:LpRuw3gJ5nRNyvvsHmpXYiiiMfrzaM73K9Rozybl9gg=
|
||||
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
||||
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
||||
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
||||
|
|
@ -7,3 +7,4 @@ h1:vP7u0iEZCJNzW1k3wWjwrXVO7WsP44Hj8aa5BFUfn/c=
|
|||
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=
|
||||
20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y=
|
||||
20260118165004_add_unique_for_xero.sql h1:gdsqkAeuGG2SmeCRGEBw39RAAGAoZiF5LF/0HfTBZ0w=
|
||||
20260118191050_add_more_info_on_xero_for_refresh_token.sql h1:ybrF538zPFYK2mjgatJmrbDZu5MBP5T+aY5no9wkyw0=
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { eq } from "drizzle-orm";
|
|||
type XeroTokenResponse = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number; // seconds
|
||||
};
|
||||
|
||||
type XeroTenant = {
|
||||
|
|
@ -17,7 +18,6 @@ export async function GET(req: NextRequest) {
|
|||
const cookieStore = await cookies();
|
||||
const session = cookieStore.get("session");
|
||||
|
||||
// Must be logged in
|
||||
if (!session) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login", process.env.APP_URL)
|
||||
|
|
@ -36,7 +36,7 @@ export async function GET(req: NextRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
// 1️⃣ Exchange code for tokens
|
||||
const tokenRes = await fetch("https://identity.xero.com/connect/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -64,15 +64,12 @@ export async function GET(req: NextRequest) {
|
|||
|
||||
const tokenData = (await tokenRes.json()) as XeroTokenResponse;
|
||||
|
||||
// Fetch connected tenants (organisations)
|
||||
const tenantRes = await fetch(
|
||||
"https://api.xero.com/connections",
|
||||
{
|
||||
// 2️⃣ Fetch tenant
|
||||
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;
|
||||
|
|
@ -84,16 +81,28 @@ export async function GET(req: NextRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
// Save user ↔ tenant (minimal MVP)
|
||||
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000);
|
||||
|
||||
// 3️⃣ Persist EVERYTHING (this is the fix)
|
||||
await db
|
||||
.insert(xeroConnections)
|
||||
.values({
|
||||
userId,
|
||||
tenantId,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: xeroConnections.userId,
|
||||
set: { tenantId },
|
||||
set: {
|
||||
tenantId,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.redirect(
|
||||
|
|
|
|||
|
|
@ -3,11 +3,25 @@ import { users } from "./users";
|
|||
|
||||
export const xeroConnections = pgTable("xero_connections", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
tenantId: text("tenant_id").notNull(),
|
||||
|
||||
// 🔐 OAuth tokens
|
||||
accessToken: text("access_token").notNull(),
|
||||
refreshToken: text("refresh_token").notNull(),
|
||||
|
||||
// When access_token expires
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
|
|
|||
66
stripe_to_invoice/lib/xero/auth.ts
Normal file
66
stripe_to_invoice/lib/xero/auth.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import "server-only";
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { xeroConnections } from "@/lib/schema/xeroConnections";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Returns a valid Xero access token for the given user.
|
||||
* Refreshes and persists tokens automatically if expired.
|
||||
*/
|
||||
export async function getValidXeroAccessToken(userId: string): Promise<string> {
|
||||
const conn = await db.query.xeroConnections.findFirst({
|
||||
where: (t, { eq }) => eq(t.userId, userId),
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
throw new Error("No Xero connection");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Access token still valid (60s safety buffer)
|
||||
if (now < conn.expiresAt.getTime() - 60_000) {
|
||||
return conn.accessToken;
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
const res = 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: "refresh_token",
|
||||
refresh_token: conn.refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Failed to refresh Xero token: ${text}`);
|
||||
}
|
||||
|
||||
const tokens: {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
} = await res.json();
|
||||
|
||||
await db
|
||||
.update(xeroConnections)
|
||||
.set({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token, // 🔥 must overwrite
|
||||
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(xeroConnections.id, conn.id));
|
||||
|
||||
return tokens.access_token;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue