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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user