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): Promise { 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>(tokens: OAuthTokens, q: string): Promise { 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 } }, }