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 = { 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 { 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) } }