Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -0,0 +1,142 @@
|
||||
import { and, eq, gt } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { accounting_connections, rent_payments, expenses } from "@/lib/db/schema"
|
||||
import { encrypt, decrypt } from "@/lib/crypto"
|
||||
import { quickbooks } from "./quickbooks"
|
||||
import { xero } from "./xero"
|
||||
import type { AccountingProvider, OAuthTokens, Provider } from "./types"
|
||||
|
||||
export type { Provider } from "./types"
|
||||
|
||||
const PROVIDERS: Record<Provider, AccountingProvider> = { quickbooks, xero }
|
||||
|
||||
export function getProvider(id: string): AccountingProvider | null {
|
||||
return id === "quickbooks" || id === "xero" ? PROVIDERS[id] : null
|
||||
}
|
||||
|
||||
export function listProviders() {
|
||||
return (Object.keys(PROVIDERS) as Provider[]).map((id) => ({
|
||||
id,
|
||||
label: PROVIDERS[id].label,
|
||||
configured: PROVIDERS[id].configured(),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Upsert an encrypted connection for (owner, provider). */
|
||||
export async function saveConnection(ownerId: string, provider: Provider, tokens: OAuthTokens) {
|
||||
const values = {
|
||||
user_id: ownerId,
|
||||
provider,
|
||||
access_token: encrypt(tokens.accessToken),
|
||||
refresh_token: encrypt(tokens.refreshToken),
|
||||
expires_at: tokens.expiresAt,
|
||||
realm_id: tokens.realmId,
|
||||
org_name: tokens.orgName,
|
||||
status: "active" as const,
|
||||
last_error: null,
|
||||
}
|
||||
const existing = await db.query.accounting_connections.findFirst({
|
||||
where: and(eq(accounting_connections.user_id, ownerId), eq(accounting_connections.provider, provider)),
|
||||
columns: { id: true },
|
||||
})
|
||||
if (existing) {
|
||||
await db.update(accounting_connections).set({ ...values, updated_at: new Date().toISOString() }).where(eq(accounting_connections.id, existing.id))
|
||||
} else {
|
||||
await db.insert(accounting_connections).values(values)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConnection(ownerId: string, provider: Provider) {
|
||||
return db.query.accounting_connections.findFirst({
|
||||
where: and(eq(accounting_connections.user_id, ownerId), eq(accounting_connections.provider, provider)),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listConnections(ownerId: string) {
|
||||
const rows = await db.query.accounting_connections.findMany({ where: eq(accounting_connections.user_id, ownerId) })
|
||||
// Never leak tokens to callers.
|
||||
return rows.map((r) => ({ provider: r.provider, orgName: r.org_name, status: r.status, lastSyncAt: r.last_sync_at, lastError: r.last_error }))
|
||||
}
|
||||
|
||||
export async function disconnect(ownerId: string, provider: Provider) {
|
||||
await db.delete(accounting_connections).where(and(eq(accounting_connections.user_id, ownerId), eq(accounting_connections.provider, provider)))
|
||||
}
|
||||
|
||||
/** Load tokens, refreshing (and persisting) if the access token is near expiry. */
|
||||
async function getValidTokens(ownerId: string, provider: Provider): Promise<OAuthTokens | null> {
|
||||
const conn = await getConnection(ownerId, provider)
|
||||
if (!conn) return null
|
||||
let tokens: OAuthTokens = {
|
||||
accessToken: decrypt(conn.access_token),
|
||||
refreshToken: decrypt(conn.refresh_token),
|
||||
expiresAt: conn.expires_at,
|
||||
realmId: conn.realm_id,
|
||||
orgName: conn.org_name,
|
||||
}
|
||||
const nearExpiry = conn.expires_at && new Date(conn.expires_at).getTime() - Date.now() < 60_000
|
||||
if (nearExpiry) {
|
||||
tokens = await getProvider(provider)!.refresh(tokens)
|
||||
await saveConnection(ownerId, provider, tokens)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* One-way sync: push rent income + expenses created since the last sync into the
|
||||
* connected provider. Uses a created_at watermark for idempotency (records synced
|
||||
* once are not re-pushed).
|
||||
*/
|
||||
export async function syncNow(ownerId: string, provider: Provider): Promise<{ income: number; expense: number }> {
|
||||
const prov = getProvider(provider)
|
||||
if (!prov) throw new Error("Unknown provider")
|
||||
const conn = await getConnection(ownerId, provider)
|
||||
if (!conn) throw new Error("Not connected")
|
||||
|
||||
try {
|
||||
const tokens = (await getValidTokens(ownerId, provider))!
|
||||
const since = conn.last_sync_at ?? "1970-01-01T00:00:00.000Z"
|
||||
|
||||
const paid = await db.query.rent_payments.findMany({
|
||||
where: and(eq(rent_payments.user_id, ownerId), eq(rent_payments.status, "paid"), gt(rent_payments.created_at, since)),
|
||||
columns: { id: true, amount: true, paid_date: true, due_date: true, tenant_id: true },
|
||||
with: { tenant: { columns: { first_name: true, last_name: true } } },
|
||||
})
|
||||
const exp = await db.query.expenses.findMany({
|
||||
where: and(eq(expenses.user_id, ownerId), gt(expenses.created_at, since)),
|
||||
columns: { id: true, amount: true, expense_date: true, category: true, vendor: true, description: true },
|
||||
})
|
||||
|
||||
const income = await prov.pushIncome(
|
||||
tokens,
|
||||
paid.map((p) => ({
|
||||
externalId: p.id,
|
||||
date: (p.paid_date ?? p.due_date ?? "").slice(0, 10),
|
||||
amount: Number(p.amount),
|
||||
customerName: `${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}`.trim() || "Tenant",
|
||||
description: `Rent payment`,
|
||||
}))
|
||||
)
|
||||
const expense = await prov.pushExpense(
|
||||
tokens,
|
||||
exp.map((e) => ({
|
||||
externalId: e.id,
|
||||
date: (e.expense_date ?? "").slice(0, 10),
|
||||
amount: Number(e.amount),
|
||||
category: e.category ?? "General",
|
||||
vendor: e.vendor ?? null,
|
||||
description: e.description ?? e.category ?? "Expense",
|
||||
}))
|
||||
)
|
||||
|
||||
await db
|
||||
.update(accounting_connections)
|
||||
.set({ last_sync_at: new Date().toISOString(), status: "active", last_error: null, updated_at: new Date().toISOString() })
|
||||
.where(eq(accounting_connections.id, conn.id))
|
||||
|
||||
return { income: income.synced, expense: expense.synced }
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message.slice(0, 500)
|
||||
await db.update(accounting_connections).set({ status: "error", last_error: msg, updated_at: new Date().toISOString() }).where(eq(accounting_connections.id, conn.id))
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types"
|
||||
import { redirectUri } from "./types"
|
||||
|
||||
// QuickBooks Online. Docs: https://developer.intuit.com/app/developer/qbo/docs/develop
|
||||
const CLIENT_ID = process.env.QBO_CLIENT_ID ?? ""
|
||||
const CLIENT_SECRET = process.env.QBO_CLIENT_SECRET ?? ""
|
||||
const ENV = (process.env.QBO_ENVIRONMENT ?? "sandbox").toLowerCase()
|
||||
const API_BASE = ENV === "production" ? "https://quickbooks.api.intuit.com" : "https://sandbox-quickbooks.api.intuit.com"
|
||||
const TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
|
||||
const MINOR = "73"
|
||||
|
||||
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", Accept: "application/json" },
|
||||
body: new URLSearchParams(form),
|
||||
})
|
||||
if (!res.ok) throw new Error(`QuickBooks 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}/v3/company/${tokens.realmId}/${path}${path.includes("?") ? "&" : "?"}minorversion=${MINOR}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}`, Accept: "application/json", "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) throw new Error(`QuickBooks API ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function query<T = Record<string, unknown>>(tokens: OAuthTokens, q: string): Promise<T[]> {
|
||||
const j = await api(tokens, `query?query=${encodeURIComponent(q)}`)
|
||||
const key = Object.keys(j.QueryResponse ?? {}).find((k) => Array.isArray(j.QueryResponse[k]))
|
||||
return key ? j.QueryResponse[key] : []
|
||||
}
|
||||
|
||||
export const quickbooks: AccountingProvider = {
|
||||
id: "quickbooks",
|
||||
label: "QuickBooks Online",
|
||||
configured: () => Boolean(CLIENT_ID && CLIENT_SECRET),
|
||||
|
||||
getAuthUrl(state) {
|
||||
const p = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
scope: "com.intuit.quickbooks.accounting",
|
||||
redirect_uri: redirectUri("quickbooks"),
|
||||
state,
|
||||
})
|
||||
return `https://appcenter.intuit.com/connect/oauth2?${p.toString()}`
|
||||
},
|
||||
|
||||
async exchangeCode(code, realmId) {
|
||||
const tokens = await tokenRequest({ grant_type: "authorization_code", code, redirect_uri: redirectUri("quickbooks") })
|
||||
tokens.realmId = realmId ?? null
|
||||
try {
|
||||
const info = await api(tokens, "companyinfo/" + tokens.realmId)
|
||||
tokens.orgName = info?.CompanyInfo?.CompanyName ?? null
|
||||
} catch {
|
||||
/* name is best-effort */
|
||||
}
|
||||
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 }
|
||||
// Post each rent payment as a SalesReceipt against the first Service item.
|
||||
const items = await query<{ Id: string }>(tokens, "SELECT Id FROM Item WHERE Type='Service' MAXRESULTS 1")
|
||||
const itemRef = items[0]?.Id
|
||||
if (!itemRef) throw new Error("No QuickBooks Service item found to record income against")
|
||||
let synced = 0
|
||||
for (const e of entries) {
|
||||
await api(tokens, "salesreceipt", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
TxnDate: e.date,
|
||||
PrivateNote: `${e.description} [pmn:${e.externalId}]`,
|
||||
Line: [{ Amount: e.amount, DetailType: "SalesItemLineDetail", Description: e.description, SalesItemLineDetail: { ItemRef: { value: itemRef }, Qty: 1, UnitPrice: e.amount } }],
|
||||
}),
|
||||
})
|
||||
synced++
|
||||
}
|
||||
return { synced }
|
||||
},
|
||||
|
||||
async pushExpense(tokens, entries) {
|
||||
if (!entries.length) return { synced: 0 }
|
||||
const banks = await query<{ Id: string }>(tokens, "SELECT Id FROM Account WHERE AccountType='Bank' MAXRESULTS 1")
|
||||
const expenses = await query<{ Id: string }>(tokens, "SELECT Id FROM Account WHERE AccountType='Expense' MAXRESULTS 1")
|
||||
const bankRef = banks[0]?.Id
|
||||
const expRef = expenses[0]?.Id
|
||||
if (!bankRef || !expRef) throw new Error("No QuickBooks Bank/Expense account found to record expenses against")
|
||||
let synced = 0
|
||||
for (const e of entries) {
|
||||
await api(tokens, "purchase", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
PaymentType: "Cash",
|
||||
AccountRef: { value: bankRef },
|
||||
TxnDate: e.date,
|
||||
PrivateNote: `${e.description} [pmn:${e.externalId}]`,
|
||||
Line: [{ Amount: e.amount, DetailType: "AccountBasedExpenseLineDetail", Description: e.description, AccountBasedExpenseLineDetail: { AccountRef: { value: expRef } } }],
|
||||
}),
|
||||
})
|
||||
synced++
|
||||
}
|
||||
return { synced }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider
|
||||
// and is tamper-proof, so the callback can't be forged/CSRF'd.
|
||||
const SECRET = process.env.BETTER_AUTH_SECRET ?? "dev-secret"
|
||||
|
||||
export function signState(data: { ownerId: string; provider: string }): string {
|
||||
const payload = Buffer.from(JSON.stringify(data)).toString("base64url")
|
||||
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
|
||||
return `${payload}.${sig}`
|
||||
}
|
||||
|
||||
export function verifyState(state: string): { ownerId: string; provider: string } | null {
|
||||
const [payload, sig] = state.split(".")
|
||||
if (!payload || !sig) return null
|
||||
const expect = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
|
||||
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type Provider = "quickbooks" | "xero"
|
||||
|
||||
export interface OAuthTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
/** ISO timestamp of access-token expiry, or null. */
|
||||
expiresAt: string | null
|
||||
/** QuickBooks realmId / Xero tenantId. */
|
||||
realmId: string | null
|
||||
orgName: string | null
|
||||
}
|
||||
|
||||
export interface IncomeEntry {
|
||||
externalId: string
|
||||
date: string // YYYY-MM-DD
|
||||
amount: number
|
||||
customerName: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ExpenseEntry {
|
||||
externalId: string
|
||||
date: string // YYYY-MM-DD
|
||||
amount: number
|
||||
category: string
|
||||
vendor: string | null
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface AccountingProvider {
|
||||
id: Provider
|
||||
label: string
|
||||
/** True when the provider's OAuth app credentials are configured in env. */
|
||||
configured(): boolean
|
||||
/** Build the provider's OAuth authorize URL. */
|
||||
getAuthUrl(state: string): string
|
||||
/** Exchange an authorization code for tokens. `realmId` is QBO's callback param. */
|
||||
exchangeCode(code: string, realmId?: string | null): Promise<OAuthTokens>
|
||||
/** Refresh an expired access token; returns the new token set (refresh token may rotate). */
|
||||
refresh(tokens: OAuthTokens): Promise<OAuthTokens>
|
||||
/** Push income (rent) into the books. Returns how many were written. */
|
||||
pushIncome(tokens: OAuthTokens, entries: IncomeEntry[]): Promise<{ synced: number }>
|
||||
/** Push expenses into the books. */
|
||||
pushExpense(tokens: OAuthTokens, entries: ExpenseEntry[]): Promise<{ synced: number }>
|
||||
}
|
||||
|
||||
export function redirectUri(provider: Provider): string {
|
||||
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
|
||||
return `${base}/api/integrations/${provider}/callback`
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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 }
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user