/** * QuickBooks Online API Client * Handles OAuth2 token management and all QBO REST API calls. */ import postgresClient from './postgres-client'; import { QboTokenRecord, QboTokenResponse, QboInvoice, QboPayment, QboDeposit, QboPurchase, QboJournalEntry, QboReport, QboQueryResponse, } from '@/lib/types/qbo'; const QBO_PRODUCTION_URL = 'https://quickbooks.api.intuit.com'; const QBO_SANDBOX_URL = 'https://sandbox-quickbooks.api.intuit.com'; const QBO_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer'; export class QboClient { private clientId: string; private clientSecret: string; private realmId: string; private baseUrl: string; private _reportBasis: string | null = null; constructor() { this.clientId = process.env.QBO_CLIENT_ID || ''; this.clientSecret = process.env.QBO_CLIENT_SECRET || ''; this.realmId = process.env.QBO_REALM_ID || ''; this.baseUrl = process.env.QBO_SANDBOX === 'true' ? QBO_SANDBOX_URL : QBO_PRODUCTION_URL; if (!this.clientId || !this.clientSecret || !this.realmId) { throw new Error('QBO_CLIENT_ID, QBO_CLIENT_SECRET, and QBO_REALM_ID must be set'); } console.log(`[QboClient] Using ${process.env.QBO_SANDBOX === 'true' ? 'SANDBOX' : 'PRODUCTION'} environment`); } // ─── Token Management ─────────────────────────────────────────────────────── async getValidAccessToken(): Promise { const token = await this.loadToken(); if (!token) { throw new Error('No QBO token found. Complete OAuth2 authorization first via /api/qbo/auth'); } if (new Date() < new Date(token.access_token_expires_at)) { return token.access_token; } if (new Date() >= new Date(token.refresh_token_expires_at)) { throw new Error('QBO refresh token has expired. Re-authorization required via /api/qbo/auth'); } return this.refreshAccessToken(token.refresh_token); } async loadToken(): Promise { const result = await postgresClient.query( `SELECT * FROM qbo_tokens WHERE realm_id = $1 LIMIT 1`, [this.realmId] ); return result.rows[0] || null; } async saveToken(token: QboTokenResponse): Promise { const now = new Date(); const accessExpiry = new Date(now.getTime() + token.expires_in * 1000); const refreshExpiry = new Date(now.getTime() + token.x_refresh_token_expires_in * 1000); await postgresClient.query( `INSERT INTO qbo_tokens (realm_id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, updated_at) VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT (realm_id) DO UPDATE SET access_token = EXCLUDED.access_token, refresh_token = EXCLUDED.refresh_token, access_token_expires_at = EXCLUDED.access_token_expires_at, refresh_token_expires_at = EXCLUDED.refresh_token_expires_at, updated_at = NOW()`, [this.realmId, token.access_token, token.refresh_token, accessExpiry, refreshExpiry] ); } async exchangeCodeForToken(authCode: string, redirectUri: string): Promise { const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); const body = new URLSearchParams({ grant_type: 'authorization_code', code: authCode, redirect_uri: redirectUri, }); const res = await fetch(QBO_TOKEN_URL, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', }, body: body.toString(), }); if (!res.ok) { const err = await res.text(); throw new Error(`QBO token exchange failed: ${res.status} ${err}`); } const tokenData: QboTokenResponse = await res.json(); await this.saveToken(tokenData); return tokenData; } private async refreshAccessToken(refreshToken: string): Promise { const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, }); const res = await fetch(QBO_TOKEN_URL, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', }, body: body.toString(), }); if (!res.ok) { const err = await res.text(); throw new Error(`QBO token refresh failed: ${res.status} ${err}`); } const tokenData: QboTokenResponse = await res.json(); await this.saveToken(tokenData); console.log('[QboClient] Access token refreshed successfully'); return tokenData.access_token; } getAuthorizationUrl(redirectUri: string, state: string): string { const params = new URLSearchParams({ client_id: this.clientId, scope: 'com.intuit.quickbooks.accounting', redirect_uri: redirectUri, response_type: 'code', state, }); return `https://appcenter.intuit.com/connect/oauth2?${params.toString()}`; } // ─── Core API Request ──────────────────────────────────────────────────────── private async request(path: string, options: RequestInit = {}): Promise { const accessToken = await this.getValidAccessToken(); const url = `${this.baseUrl}/v3/company/${this.realmId}${path}`; const res = await fetch(url, { ...options, redirect: 'follow', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'Content-Type': 'application/json', ...options.headers, }, }); const intuitTid = res.headers.get('intuit_tid') || res.headers.get('intuit-tid') || 'unknown'; if (!res.ok) { const err = await res.text(); throw new Error(`QBO API error ${res.status} on ${path} [intuit_tid=${intuitTid}]: ${err}`); } return res.json() as Promise; } // ─── Paginated Query ───────────────────────────────────────────────────────── private async queryAll(entity: string, extraWhere = ''): Promise { const all: T[] = []; let startPos = 1; const pageSize = 1000; while (true) { const where = extraWhere ? ` WHERE ${extraWhere}` : ''; const sql = encodeURIComponent( `SELECT * FROM ${entity}${where} STARTPOSITION ${startPos} MAXRESULTS ${pageSize}` ); const data = await this.request>(`/query?query=${sql}&minorversion=65`); const items: T[] = (data.QueryResponse[entity] as T[]) || []; all.push(...items); if (items.length < pageSize) break; startPos += pageSize; } return all; } // ─── Entity Fetchers ───────────────────────────────────────────────────────── async getInvoices(updatedSince?: Date): Promise { const where = updatedSince ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` : undefined; return this.queryAll('Invoice', where); } async getPayments(updatedSince?: Date): Promise { const where = updatedSince ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` : undefined; return this.queryAll('Payment', where); } async getDeposits(updatedSince?: Date): Promise { const where = updatedSince ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` : undefined; return this.queryAll('Deposit', where); } async getPurchases(updatedSince?: Date): Promise { const where = updatedSince ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` : undefined; return this.queryAll('Purchase', where); } async getJournalEntries(updatedSince?: Date): Promise { const where = updatedSince ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` : undefined; return this.queryAll('JournalEntry', where); } async getPreferences(): Promise<{ reportBasis: string }> { if (this._reportBasis) return { reportBasis: this._reportBasis }; const data = await this.request<{ Preferences?: { ReportPrefs?: { ReportBasis?: string } } }>('/preferences?minorversion=65'); this._reportBasis = data?.Preferences?.ReportPrefs?.ReportBasis ?? 'Accrual'; return { reportBasis: this._reportBasis }; } async getProfitAndLoss(startDate: string, endDate: string, accountingMethod?: string): Promise { const params = new URLSearchParams({ start_date: startDate, end_date: endDate, accounting_method: accountingMethod ?? 'Accrual', showrows: 'all', showcols: 'all', minorversion: '65', }); return this.request(`/reports/ProfitAndLoss?${params.toString()}`); } async getBalanceSheet(startDate: string, endDate: string, accountingMethod?: string): Promise { const params = new URLSearchParams({ start_date: startDate, end_date: endDate, accounting_method: accountingMethod ?? 'Accrual', showrows: 'all', showcols: 'all', minorversion: '65', }); return this.request(`/reports/BalanceSheet?${params.toString()}`); } async getCashFlow(startDate: string, endDate: string): Promise { const params = new URLSearchParams({ start_date: startDate, end_date: endDate, minorversion: '65', }); return this.request(`/reports/CashFlow?${params.toString()}`); } } let _instance: QboClient | null = null; export function getQboClient(): QboClient { if (!_instance) { _instance = new QboClient(); } return _instance; }