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=
|
0001_init.sql h1:gzb02ZbjrrJkXOC+2qIZsngnj7A+29O2/b4awScPlPs=
|
||||||
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
0002_auth.sql h1:4NhBu26dIBMy9gxMxM3tf6Z2CS2kfKlGjFBj07T/aBw=
|
||||||
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
0003_stripe_xero.sql h1:E2bcdUDnondsXwbdIwVlZqR4DQwzcoDiyeRFJwVxXwg=
|
||||||
|
|
@ -7,3 +7,4 @@ h1:vP7u0iEZCJNzW1k3wWjwrXVO7WsP44Hj8aa5BFUfn/c=
|
||||||
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=
|
20251230154354_add_used_at_to_login_tokens.sql h1:FIP2MMRnfhi4hmFC3VBuABZZrxZQ1icranrXy0ljERc=
|
||||||
20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y=
|
20260118151944_add_unique_index_to_stripe_accounts.sql h1:PyI8cM8Xyn4bG7BBlD7YRwK1iRQ8HPfzf0r1+Swfe1Y=
|
||||||
20260118165004_add_unique_for_xero.sql h1:gdsqkAeuGG2SmeCRGEBw39RAAGAoZiF5LF/0HfTBZ0w=
|
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 = {
|
type XeroTokenResponse = {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
|
expires_in: number; // seconds
|
||||||
};
|
};
|
||||||
|
|
||||||
type XeroTenant = {
|
type XeroTenant = {
|
||||||
|
|
@ -17,7 +18,6 @@ export async function GET(req: NextRequest) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const session = cookieStore.get("session");
|
const session = cookieStore.get("session");
|
||||||
|
|
||||||
// Must be logged in
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
new URL("/login", process.env.APP_URL)
|
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", {
|
const tokenRes = await fetch("https://identity.xero.com/connect/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -64,15 +64,12 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
const tokenData = (await tokenRes.json()) as XeroTokenResponse;
|
const tokenData = (await tokenRes.json()) as XeroTokenResponse;
|
||||||
|
|
||||||
// Fetch connected tenants (organisations)
|
// 2️⃣ Fetch tenant
|
||||||
const tenantRes = await fetch(
|
const tenantRes = await fetch("https://api.xero.com/connections", {
|
||||||
"https://api.xero.com/connections",
|
|
||||||
{
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokenData.access_token}`,
|
Authorization: `Bearer ${tokenData.access_token}`,
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const tenants = (await tenantRes.json()) as XeroTenant[];
|
const tenants = (await tenantRes.json()) as XeroTenant[];
|
||||||
const tenantId = tenants[0]?.tenantId;
|
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
|
await db
|
||||||
.insert(xeroConnections)
|
.insert(xeroConnections)
|
||||||
.values({
|
.values({
|
||||||
userId,
|
userId,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
refreshToken: tokenData.refresh_token,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: xeroConnections.userId,
|
target: xeroConnections.userId,
|
||||||
set: { tenantId },
|
set: {
|
||||||
|
tenantId,
|
||||||
|
accessToken: tokenData.access_token,
|
||||||
|
refreshToken: tokenData.refresh_token,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.redirect(
|
return NextResponse.redirect(
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,25 @@ import { users } from "./users";
|
||||||
|
|
||||||
export const xeroConnections = pgTable("xero_connections", {
|
export const xeroConnections = pgTable("xero_connections", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
|
||||||
userId: uuid("user_id")
|
userId: uuid("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
tenantId: text("tenant_id").notNull(),
|
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 })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.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