143 lines
5.6 KiB
TypeScript
143 lines
5.6 KiB
TypeScript
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)
|
||
|
|
}
|
||
|
|
}
|