Batch commit of the pending working tree on security/audit-fixes-2026-07. Major areas: - Outbound webhooks / Zapier: schema + signed delivery with retries, public v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain. - Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when Spaces is unconfigured instead of silently using ephemeral disk. - Integrations & features (concurrent work): accounting (QuickBooks/Xero), e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding, expanded legal pages. - DB migrations 0006–0009. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
4.4 KiB
TypeScript
115 lines
4.4 KiB
TypeScript
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<string, string>): Promise<OAuthTokens> {
|
|
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 }
|
|
},
|
|
}
|