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:
Leon Serfaty
2026-07-02 13:42:34 -04:00
co-authored by Claude Opus 4.8
parent 969d5d4c8a
commit c9968531e4
282 changed files with 41530 additions and 4013 deletions
+142
View File
@@ -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)
}
}