import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types" import { redirectUri } from "./types" // Xero. Docs: https://developer.xero.com/documentation/guides/oauth2/ const CLIENT_ID = process.env.XERO_CLIENT_ID ?? "" const CLIENT_SECRET = process.env.XERO_CLIENT_SECRET ?? "" const TOKEN_URL = "https://identity.xero.com/connect/token" const API_BASE = "https://api.xero.com/api.xro/2.0" const SCOPES = "openid profile email accounting.transactions accounting.contacts offline_access" // Standard default account codes (present in most Xero orgs). const SALES_CODE = process.env.XERO_SALES_ACCOUNT_CODE ?? "200" const EXPENSE_CODE = process.env.XERO_EXPENSE_ACCOUNT_CODE ?? "400" function basicAuth() { return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64") } async function tokenRequest(form: Record): Promise { const res = await fetch(TOKEN_URL, { method: "POST", headers: { Authorization: basicAuth(), "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form), }) if (!res.ok) throw new Error(`Xero token error ${res.status}: ${await res.text()}`) const j = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number } return { accessToken: j.access_token, refreshToken: j.refresh_token, expiresAt: new Date(Date.now() + j.expires_in * 1000).toISOString(), realmId: null, orgName: null, } } async function api(tokens: OAuthTokens, path: string, init?: RequestInit) { const res = await fetch(`${API_BASE}/${path}`, { ...init, headers: { Authorization: `Bearer ${tokens.accessToken}`, "Xero-tenant-id": tokens.realmId ?? "", Accept: "application/json", "Content-Type": "application/json", ...(init?.headers ?? {}), }, }) if (!res.ok) throw new Error(`Xero API ${res.status}: ${(await res.text()).slice(0, 300)}`) return res.json() } export const xero: AccountingProvider = { id: "xero", label: "Xero", configured: () => Boolean(CLIENT_ID && CLIENT_SECRET), getAuthUrl(state) { const p = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: redirectUri("xero"), scope: SCOPES, state, }) return `https://login.xero.com/identity/connect/authorize?${p.toString()}` }, async exchangeCode(code) { const tokens = await tokenRequest({ grant_type: "authorization_code", code, redirect_uri: redirectUri("xero") }) // Resolve the connected organisation (tenant). const res = await fetch("https://api.xero.com/connections", { headers: { Authorization: `Bearer ${tokens.accessToken}`, Accept: "application/json" } }) const conns = (await res.json()) as Array<{ tenantId: string; tenantName: string }> tokens.realmId = conns[0]?.tenantId ?? null tokens.orgName = conns[0]?.tenantName ?? null return tokens }, async refresh(tokens) { const next = await tokenRequest({ grant_type: "refresh_token", refresh_token: tokens.refreshToken }) next.realmId = tokens.realmId next.orgName = tokens.orgName return next }, async pushIncome(tokens, entries) { if (!entries.length) return { synced: 0 } // ACCREC invoices (sales) against the standard sales account. const invoices = entries.map((e) => ({ Type: "ACCREC", Contact: { Name: e.customerName }, Date: e.date, DueDate: e.date, Status: "AUTHORISED", Reference: `pmn:${e.externalId}`, LineItems: [{ Description: e.description, Quantity: 1, UnitAmount: e.amount, AccountCode: SALES_CODE }], })) await api(tokens, "Invoices", { method: "POST", body: JSON.stringify({ Invoices: invoices }) }) return { synced: entries.length } }, async pushExpense(tokens, entries) { if (!entries.length) return { synced: 0 } // ACCPAY invoices (bills) against the standard expense account. const invoices = entries.map((e) => ({ Type: "ACCPAY", Contact: { Name: e.vendor || "Expense" }, Date: e.date, DueDate: e.date, Status: "AUTHORISED", Reference: `pmn:${e.externalId}`, LineItems: [{ Description: e.description, Quantity: 1, UnitAmount: e.amount, AccountCode: EXPENSE_CODE }], })) await api(tokens, "Invoices", { method: "POST", body: JSON.stringify({ Invoices: invoices }) }) return { synced: entries.length } }, }