128 lines
5.2 KiB
TypeScript
128 lines
5.2 KiB
TypeScript
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<string, string>): Promise<OAuthTokens> {
|
||
|
|
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<T = Record<string, unknown>>(tokens: OAuthTokens, q: string): Promise<T[]> {
|
||
|
|
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 }
|
||
|
|
},
|
||
|
|
}
|