diff --git a/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql b/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql new file mode 100644 index 0000000..e29469c --- /dev/null +++ b/db/atlas/stripe_invoice/migrations/20260118191050_add_more_info_on_xero_for_refresh_token.sql @@ -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); diff --git a/db/atlas/stripe_invoice/migrations/atlas.sum b/db/atlas/stripe_invoice/migrations/atlas.sum index ee86f48..9b9d97d 100644 --- a/db/atlas/stripe_invoice/migrations/atlas.sum +++ b/db/atlas/stripe_invoice/migrations/atlas.sum @@ -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= diff --git a/stripe_to_invoice/app/api/xero/callback/route.ts b/stripe_to_invoice/app/api/xero/callback/route.ts index 0a16570..1046c24 100644 --- a/stripe_to_invoice/app/api/xero/callback/route.ts +++ b/stripe_to_invoice/app/api/xero/callback/route.ts @@ -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", - { - headers: { - Authorization: `Bearer ${tokenData.access_token}`, - }, - } - ); + // 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( diff --git a/stripe_to_invoice/lib/schema/xeroConnections.ts b/stripe_to_invoice/lib/schema/xeroConnections.ts index eeb0c4b..173681d 100644 --- a/stripe_to_invoice/lib/schema/xeroConnections.ts +++ b/stripe_to_invoice/lib/schema/xeroConnections.ts @@ -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(), }); diff --git a/stripe_to_invoice/lib/xero/auth.ts b/stripe_to_invoice/lib/xero/auth.ts new file mode 100644 index 0000000..323e981 --- /dev/null +++ b/stripe_to_invoice/lib/xero/auth.ts @@ -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 { + 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; +}